diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index 8e4a0c3..de1aa74 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -22,13 +22,16 @@ public class AuthFilter extends OncePerRequestFilter { private static final String JWT_COOKIE_NAME = "kcg_token"; private static final String AUTH_PATH_PREFIX = "/api/auth/"; private static final String SENSOR_PATH_PREFIX = "/api/sensor/"; + private static final String CCTV_PATH_PREFIX = "/api/cctv/"; private final JwtProvider jwtProvider; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - return path.startsWith(AUTH_PATH_PREFIX) || path.startsWith(SENSOR_PATH_PREFIX); + return path.startsWith(AUTH_PATH_PREFIX) + || path.startsWith(SENSOR_PATH_PREFIX) + || path.startsWith(CCTV_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/cctv/CctvProxyController.java b/backend/src/main/java/gc/mda/kcg/cctv/CctvProxyController.java new file mode 100644 index 0000000..777e402 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/cctv/CctvProxyController.java @@ -0,0 +1,46 @@ +package gc.mda.kcg.cctv; + +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; + +/** + * KHOA CCTV HLS 스트림 프록시 + * CORS 우회를 위해 백엔드에서 KHOA HLS 요청을 중계한다. + * GET /api/cctv/hls/{site}/{filename} + */ +@RestController +@RequestMapping("/api/cctv") +public class CctvProxyController { + + private static final String KHOA_HLS_BASE = + "https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa"; + + private final RestTemplate rest = new RestTemplate(); + + @GetMapping("/hls/{site}/{filename}") + public ResponseEntity proxyHls( + @PathVariable String site, + @PathVariable String filename) { + + String url = KHOA_HLS_BASE + "/" + site + "/" + filename; + + HttpHeaders reqHeaders = new HttpHeaders(); + reqHeaders.set("User-Agent", "Mozilla/5.0 (compatible; KCG-Monitor/1.0)"); + + ResponseEntity upstream = rest.exchange( + url, HttpMethod.GET, new HttpEntity<>(reqHeaders), byte[].class); + + HttpHeaders resHeaders = new HttpHeaders(); + if (filename.endsWith(".m3u8")) { + resHeaders.setContentType(MediaType.parseMediaType("application/vnd.apple.mpegurl")); + } else if (filename.endsWith(".ts")) { + resHeaders.setContentType(MediaType.parseMediaType("video/mp2t")); + } else { + resHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM); + } + resHeaders.setCacheControl(CacheControl.noCache()); + + return new ResponseEntity<>(upstream.getBody(), resHeaders, HttpStatus.OK); + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index d6d0a08..f11d598 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -252,9 +252,10 @@ .layer-toggle { display: flex; align-items: center; - gap: 8px; - padding: 5px 8px; + gap: 6px; + padding: 3px 6px; border: none; + font-size: 10px; border-radius: 4px; background: transparent; color: var(--kcg-dim); @@ -274,8 +275,8 @@ } .layer-dot { - width: 8px; - height: 8px; + width: 6px; + height: 6px; border-radius: 50%; flex-shrink: 0; } @@ -326,9 +327,9 @@ align-items: center; gap: 4px; cursor: pointer; - padding: 4px 8px; + padding: 3px 6px; border-radius: 4px; - font-size: 11px; + font-size: 10px; font-weight: 500; transition: background 0.15s; } @@ -370,6 +371,21 @@ text-overflow: ellipsis; } .category-count { margin-left: auto; color: var(--kcg-muted); font-size: 9px; } + +/* Nationality filter section */ +.nationality-divider { + height: 1px; + background: var(--kcg-border); + margin: 6px 0 4px; +} +.nationality-header { + font-size: 9px; + font-weight: 600; + color: var(--kcg-dim); + padding: 2px 8px; + letter-spacing: 0.5px; +} + .legend-toggle { font-size: 9px; color: var(--kcg-dim); @@ -1852,9 +1868,9 @@ /* MapLibre GL popup override */ .gl-popup .maplibregl-popup-content, .event-popup .maplibregl-popup-content { - background: var(--kcg-glass-dense) !important; - color: var(--kcg-text) !important; - border: 1px solid var(--kcg-border-light) !important; + background: rgba(10, 10, 26, 0.96) !important; + color: #e0e0e0 !important; + border: 1px solid #444 !important; border-radius: 6px !important; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important; padding: 10px !important; @@ -1862,12 +1878,12 @@ .gl-popup .maplibregl-popup-tip, .event-popup .maplibregl-popup-tip { - border-top-color: var(--kcg-glass-dense) !important; + border-top-color: rgba(10, 10, 26, 0.96) !important; } .gl-popup .maplibregl-popup-close-button, .event-popup .maplibregl-popup-close-button { - color: var(--kcg-muted) !important; + color: #aaa !important; font-size: 18px; right: 4px; top: 2px; @@ -1875,17 +1891,17 @@ /* Override default white popup background globally */ .maplibregl-popup-content { - background: var(--kcg-glass-dense) !important; - color: var(--kcg-text) !important; - border: 1px solid var(--kcg-border-light) !important; + background: rgba(10, 10, 26, 0.96) !important; + color: #e0e0e0 !important; + border: 1px solid #444 !important; border-radius: 6px !important; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.8) !important; } .maplibregl-popup-tip { - border-top-color: var(--kcg-glass-dense) !important; + border-top-color: rgba(10, 10, 26, 0.96) !important; } .maplibregl-popup-close-button { - color: var(--kcg-muted) !important; + color: #aaa !important; } /* GL marker labels (replaces Leaflet tooltips) */ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b98f35..986682b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,6 +79,12 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { osint: true, eez: true, piracy: true, + windFarm: true, + ports: true, + militaryBases: true, + govBuildings: true, + nkLaunch: true, + nkMissile: true, militaryOnly: false, }); @@ -106,6 +112,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }); }, []); + // Nationality filter state (Korea tab) + const [hiddenNationalities, setHiddenNationalities] = useState>(new Set()); + const toggleNationality = useCallback((nat: string) => { + setHiddenNationalities(prev => { + const next = new Set(prev); + if (next.has(nat)) { next.delete(nat); } else { next.add(nat); } + return next; + }); + }, []); + const [flyToTarget, setFlyToTarget] = useState(null); // 1시간마다 전체 데이터 강제 리프레시 @@ -154,6 +170,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { isLive, hiddenAcCategories, hiddenShipCategories, + hiddenNationalities, refreshKey, }); @@ -535,17 +552,26 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { { 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: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59 }, { 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' }, + { key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8 }, + { key: 'ports', label: '항구', color: '#3b82f6', count: 46 }, + { key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38 }, + { key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32 }, + { key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19 }, + { key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4 }, ]} hiddenAcCategories={hiddenAcCategories} hiddenShipCategories={hiddenShipCategories} onAcCategoryToggle={toggleAcCategory} onShipCategoryToggle={toggleShipCategory} + shipsByNationality={koreaData.shipsByNationality} + hiddenNationalities={hiddenNationalities} + onNationalityToggle={toggleNationality} /> diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index 0481666..996430e 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -613,19 +613,17 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, const moving = list.filter(s => s.speed > 0.5).length; const anchored = list.length - moving; return ( -
- - {mtLabel} - - {list.length}{t('common:units.vessels')} - - - {moving > 0 && {t('ships:status.underway')} {moving}} - {anchored > 0 && {t('ships:status.anchored')} {anchored}} + + {mtLabel} + + {list.length}{t('common:units.vessels')} + {t('ships:status.underway')} {moving} + {t('ships:status.anchored')} {anchored}
); })} @@ -669,19 +667,17 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, const moving = list.filter(s => s.speed > 0.5).length; const anchored = list.length - moving; return ( -
- - {mtLabel} - - {list.length}{t('common:units.vessels')} - - - {moving > 0 && {t('ships:status.underway')} {moving}} - {anchored > 0 && {t('ships:status.anchored')} {anchored}} + + {mtLabel} + + {list.length}{t('common:units.vessels')} + {t('ships:status.underway')} {moving} + {t('ships:status.anchored')} {anchored}
); })} diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index 72fd261..a572f76 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -65,6 +65,23 @@ const SHIP_TYPE_LEGEND: [string, string][] = [ 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; +// Nationality categories for Korea tab +const NAT_CATEGORIES = ['KR', 'CN', 'KP', 'JP', 'unclassified'] as const; +const NAT_LABELS: Record = { + KR: '🇰🇷 한국', + CN: '🇨🇳 중국', + KP: '🇰🇵 북한', + JP: '🇯🇵 일본', + unclassified: '🏳️ 미분류', +}; +const NAT_COLORS: Record = { + KR: '#3b82f6', + CN: '#ef4444', + KP: '#f97316', + JP: '#f472b6', + unclassified: '#6b7280', +}; + interface ExtraLayer { key: string; label: string; @@ -85,6 +102,9 @@ interface LayerPanelProps { hiddenShipCategories: Set; onAcCategoryToggle: (cat: string) => void; onShipCategoryToggle: (cat: string) => void; + shipsByNationality?: Record; + hiddenNationalities?: Set; + onNationalityToggle?: (nat: string) => void; } export function LayerPanel({ @@ -100,6 +120,9 @@ export function LayerPanel({ hiddenShipCategories, onAcCategoryToggle, onShipCategoryToggle, + shipsByNationality, + hiddenNationalities, + onNationalityToggle, }: LayerPanelProps) { const { t } = useTranslation(['common', 'ships']); const [expanded, setExpanded] = useState>(new Set(['aircraft', 'ships'])); @@ -235,35 +258,74 @@ export function LayerPanel({ ); })} - {/* Ship type legend */} - - {legendOpen.has('shipType') && ( -
-
- {SHIP_TYPE_LEGEND.map(([key, color]) => ( -
- - {t(`ships:mtType.${key}`, key)} + {/* Ship type legend (Korea tab only) */} + {shipsByNationality && ( + <> + + {legendOpen.has('shipType') && ( +
+
+ {SHIP_TYPE_LEGEND.map(([key, color]) => ( +
+ + {t(`ships:mtType.${key}`, key)} +
+ ))}
- ))} -
+
+ )} + + )} + +
+ )} + + {/* Nationality tree (Korea tab only) */} + {shipsByNationality && hiddenNationalities && onNationalityToggle && ( + <> + a + b, 0)})`} + color="#8b5cf6" + active + expandable + isExpanded={expanded.has('nationality')} + onToggle={() => toggleExpand('nationality')} + onExpand={() => toggleExpand('nationality')} + /> + {expanded.has('nationality') && ( +
+ {NAT_CATEGORIES.map(nat => { + const count = shipsByNationality[nat] || 0; + if (count === 0) return null; + return ( +
)} -
+ )} {/* Satellites (simple toggle) */} diff --git a/frontend/src/components/korea/CctvLayer.tsx b/frontend/src/components/korea/CctvLayer.tsx index 30604e1..8ffae6c 100644 --- a/frontend/src/components/korea/CctvLayer.tsx +++ b/frontend/src/components/korea/CctvLayer.tsx @@ -12,9 +12,9 @@ const REGION_COLOR: Record = { '동해': '#74c0fc', }; -/** KHOA HLS → vite 프록시 경유 */ +/** 백엔드 프록시 경유 — streamUrl이 이미 /api/kcg/cctv/hls/... 형태 */ function toProxyUrl(cam: CctvCamera): string { - return cam.streamUrl.replace('https://www.khoa.go.kr', '/api/khoa-hls'); + return cam.streamUrl; } export function CctvLayer() { @@ -55,39 +55,48 @@ export function CctvLayer() { setSelected(null)} closeOnClick={false} anchor="bottom" maxWidth="280px" className="gl-popup"> -
+
+ {/* 헤더 - 팝업 가장자리까지 꽉 채움 */}
📹 {selected.name}
-
- + {/* 태그 */} +
+ ● {t('cctv.live')} {selected.region} - + {t(`cctv.type.${selected.type}`, { defaultValue: selected.type })} - + {t('cctv.khoa')}
-
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
- + {/* 좌표 */} +
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
+ {/* 버튼 */} +
)} @@ -172,18 +181,26 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi {/* Modal */}
e.stopPropagation()} - className="w-[640px] max-w-[90vw] bg-kcg-bg rounded-lg overflow-hidden" + className="bg-kcg-bg rounded-lg overflow-hidden" style={{ + width: 640, + maxWidth: '90vw', border: `1px solid ${color}`, boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`, }} > {/* Header */} -
-
+
+
@@ -191,13 +208,14 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi 📹 {cam.name} {cam.region}
@@ -212,7 +230,7 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi {status === 'loading' && (
📹
-
{t('cctv.connectingEllipsis')}
+
{t('cctv.connectingEllipsis')}
)} @@ -224,7 +242,8 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi href={cam.url} target="_blank" rel="noopener noreferrer" - className="text-[10px] text-kcg-accent font-mono underline" + className="text-kcg-accent font-mono underline" + style={{ fontSize: 10 }} >{t('cctv.viewOnBadatime')}
)} @@ -232,14 +251,14 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi {status === 'playing' && ( <>
- + {cam.name} - + ● {t('cctv.rec')}
-
+
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
@@ -247,7 +266,10 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
{/* Footer info */} -
+
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E {t('cctv.khoaFull')}
diff --git a/frontend/src/components/korea/GovBuildingLayer.tsx b/frontend/src/components/korea/GovBuildingLayer.tsx new file mode 100644 index 0000000..0ce0401 --- /dev/null +++ b/frontend/src/components/korea/GovBuildingLayer.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { GOV_BUILDINGS } from '../../data/govBuildings'; +import type { GovBuilding } from '../../data/govBuildings'; + +const COUNTRY_STYLE: Record = { + CN: { color: '#ef4444', flag: '🇨🇳', label: '중국' }, + JP: { color: '#f472b6', flag: '🇯🇵', label: '일본' }, +}; + +const TYPE_STYLE: Record = { + executive: { icon: '🏛️', label: '행정부', color: '#f59e0b' }, + legislature: { icon: '🏛️', label: '입법부', color: '#a78bfa' }, + military_hq: { icon: '⭐', label: '군사본부', color: '#ef4444' }, + intelligence: { icon: '🔍', label: '정보기관', color: '#6366f1' }, + foreign: { icon: '🌐', label: '외교부', color: '#3b82f6' }, + maritime: { icon: '⚓', label: '해양기관', color: '#06b6d4' }, + defense: { icon: '🛡️', label: '국방부', color: '#dc2626' }, +}; + +export function GovBuildingLayer() { + const [selected, setSelected] = useState(null); + + return ( + <> + {GOV_BUILDINGS.map(g => { + const ts = TYPE_STYLE[g.type] || TYPE_STYLE.executive; + return ( + { e.originalEvent.stopPropagation(); setSelected(g); }}> +
+
+ {ts.icon} +
+
+ {g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo} +
+
+
+ ); + })} + + {selected && (() => { + const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN; + const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive; + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ {cs.flag} + {ts.icon} {selected.nameKo} +
+
+ + {ts.label} + + + {cs.label} + +
+
+ {selected.description} +
+
+ {selected.name} +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ ); + })()} + + ); +} diff --git a/frontend/src/components/korea/KoreaAirportLayer.tsx b/frontend/src/components/korea/KoreaAirportLayer.tsx index 5c95573..8febd1d 100644 --- a/frontend/src/components/korea/KoreaAirportLayer.tsx +++ b/frontend/src/components/korea/KoreaAirportLayer.tsx @@ -4,6 +4,23 @@ import { Marker, Popup } from 'react-map-gl/maplibre'; import { KOREAN_AIRPORTS } from '../../services/airports'; import type { KoreanAirport } from '../../services/airports'; +const COUNTRY_COLOR: Record = { + KR: { intl: '#a78bfa', domestic: '#7c8aaa', flag: '🇰🇷', label: '한국' }, + CN: { intl: '#ef4444', domestic: '#b91c1c', flag: '🇨🇳', label: '중국' }, + JP: { intl: '#f472b6', domestic: '#9d174d', flag: '🇯🇵', label: '일본' }, + KP: { intl: '#f97316', domestic: '#c2410c', flag: '🇰🇵', label: '북한' }, + TW: { intl: '#10b981', domestic: '#059669', flag: '🇹🇼', label: '대만' }, +}; + +function getColor(ap: KoreanAirport) { + const cc = COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR; + return ap.intl ? cc.intl : cc.domestic; +} + +function getCountryInfo(ap: KoreanAirport) { + return COUNTRY_COLOR[ap.country || 'KR'] || COUNTRY_COLOR.KR; +} + export function KoreaAirportLayer() { const [selected, setSelected] = useState(null); const { t } = useTranslation(); @@ -11,9 +28,8 @@ export function KoreaAirportLayer() { return ( <> {KOREAN_AIRPORTS.map(ap => { - const isIntl = ap.intl; - const color = isIntl ? '#a78bfa' : '#7c8aaa'; - const size = isIntl ? 20 : 16; + const color = getColor(ap); + const size = ap.intl ? 20 : 16; return ( { e.originalEvent.stopPropagation(); setSelected(ap); }}> @@ -37,37 +53,59 @@ export function KoreaAirportLayer() { ); })} - {selected && ( - setSelected(null)} closeOnClick={false} - anchor="bottom" maxWidth="260px" className="gl-popup"> -
-
- {selected.nameKo} -
-
- {selected.intl && ( - - {t('airport.international')} + {selected && (() => { + const color = getColor(selected); + const info = getCountryInfo(selected); + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ {info.flag} + {selected.nameKo} +
+
+ {selected.intl && ( + + {t('airport.international')} + + )} + {selected.domestic && ( + + {t('airport.domestic')} + + )} + + {info.label} - )} - {selected.domestic && ( - - {t('airport.domestic')} - - )} - - {selected.id} / {selected.icao} - +
+
+
IATA : {selected.id}
+
ICAO : {selected.icao}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
-
- {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E -
-
- - )} + + ); + })()} ); } diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 3a5d0c3..aaea183 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -14,6 +14,12 @@ import { NavWarningLayer } from './NavWarningLayer'; import { OsintMapLayer } from './OsintMapLayer'; import { EezLayer } from './EezLayer'; import { PiracyLayer } from './PiracyLayer'; +import { WindFarmLayer } from './WindFarmLayer'; +import { PortLayer } from './PortLayer'; +import { MilitaryBaseLayer } from './MilitaryBaseLayer'; +import { GovBuildingLayer } from './GovBuildingLayer'; +import { NKLaunchLayer } from './NKLaunchLayer'; +import { NKMissileEventLayer } from './NKMissileEventLayer'; import { fetchKoreaInfra } from '../../services/infra'; import type { PowerFacility } from '../../services/infra'; import type { Ship, Aircraft, SatellitePosition } from '../../types'; @@ -255,6 +261,12 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.aircraft && aircraft.length > 0 && } {layers.cables && } {layers.cctv && } + {layers.windFarm && } + {layers.ports && } + {layers.militaryBases && } + {layers.govBuildings && } + {layers.nkLaunch && } + {layers.nkMissile && } {layers.airports && } {layers.coastGuard && } {layers.navWarning && } diff --git a/frontend/src/components/korea/MilitaryBaseLayer.tsx b/frontend/src/components/korea/MilitaryBaseLayer.tsx new file mode 100644 index 0000000..03a1396 --- /dev/null +++ b/frontend/src/components/korea/MilitaryBaseLayer.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { MILITARY_BASES } from '../../data/militaryBases'; +import type { MilitaryBase } from '../../data/militaryBases'; + +const COUNTRY_STYLE: Record = { + CN: { color: '#ef4444', flag: '🇨🇳', label: '중국' }, + JP: { color: '#f472b6', flag: '🇯🇵', label: '일본' }, + KP: { color: '#f97316', flag: '🇰🇵', label: '북한' }, + TW: { color: '#10b981', flag: '🇹🇼', label: '대만' }, +}; + +const TYPE_STYLE: Record = { + naval: { icon: '⚓', label: '해군기지', color: '#3b82f6' }, + airforce: { icon: '✈️', label: '공군기지', color: '#f59e0b' }, + army: { icon: '🪖', label: '육군기지', color: '#22c55e' }, + missile: { icon: '🚀', label: '미사일기지', color: '#ef4444' }, + joint: { icon: '⭐', label: '합동기지', color: '#a78bfa' }, +}; + +function MilIcon({ type, size = 16 }: { type: string; size?: number }) { + const ts = TYPE_STYLE[type] || TYPE_STYLE.army; + return ( + + + {ts.icon} + + ); +} + +export function MilitaryBaseLayer() { + const [selected, setSelected] = useState(null); + + return ( + <> + {MILITARY_BASES.map(base => { + const cs = COUNTRY_STYLE[base.country] || COUNTRY_STYLE.CN; + const ts = TYPE_STYLE[base.type] || TYPE_STYLE.army; + return ( + { e.originalEvent.stopPropagation(); setSelected(base); }}> +
+
+ {ts.icon} +
+
+ {base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo} +
+
+
+ ); + })} + + {selected && (() => { + const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN; + const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army; + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="300px" className="gl-popup"> +
+
+ {cs.flag} + {ts.icon} {selected.nameKo} +
+
+ + {ts.label} + + + {cs.label} + +
+
+ {selected.description} +
+
+
시설명 : {selected.name}
+
유형 : {ts.label}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ ); + })()} + + ); +} diff --git a/frontend/src/components/korea/NKLaunchLayer.tsx b/frontend/src/components/korea/NKLaunchLayer.tsx new file mode 100644 index 0000000..e4a2c73 --- /dev/null +++ b/frontend/src/components/korea/NKLaunchLayer.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { NK_LAUNCH_SITES, NK_LAUNCH_TYPE_META } from '../../data/nkLaunchSites'; +import type { NKLaunchSite } from '../../data/nkLaunchSites'; + +export function NKLaunchLayer() { + const [selected, setSelected] = useState(null); + + return ( + <> + {NK_LAUNCH_SITES.map(site => { + const meta = NK_LAUNCH_TYPE_META[site.type]; + const isArtillery = site.type === 'artillery' || site.type === 'mlrs'; + const size = isArtillery ? 14 : 18; + return ( + { e.originalEvent.stopPropagation(); setSelected(site); }}> +
+
+ {meta.icon} +
+
+ {site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo} +
+
+
+ ); + })} + + {selected && (() => { + const meta = NK_LAUNCH_TYPE_META[selected.type]; + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ 🇰🇵 + {meta.icon} {selected.nameKo} +
+
+ + {meta.label} + + + 북한 + +
+
+ {selected.description} +
+ {selected.recentUse && ( +
+ 최근: {selected.recentUse} +
+ )} +
+ {selected.name} +
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ ); + })()} + + ); +} diff --git a/frontend/src/components/korea/NKMissileEventLayer.tsx b/frontend/src/components/korea/NKMissileEventLayer.tsx new file mode 100644 index 0000000..21fcf68 --- /dev/null +++ b/frontend/src/components/korea/NKMissileEventLayer.tsx @@ -0,0 +1,181 @@ +import { useState, useMemo } from 'react'; +import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; +import { NK_MISSILE_EVENTS } from '../../data/nkMissileEvents'; +import type { NKMissileEvent } from '../../data/nkMissileEvents'; +import type { Ship } from '../../types'; +import { getMarineTrafficCategory } from '../../utils/marineTraffic'; + +function isToday(dateStr: string): boolean { + const today = new Date().toISOString().slice(0, 10); + return dateStr === today; +} + +function getMissileColor(type: string): string { + if (type.includes('ICBM')) return '#dc2626'; + if (type.includes('IRBM')) return '#ef4444'; + if (type.includes('SLBM')) return '#3b82f6'; + return '#f97316'; +} + +function distKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +interface Props { + ships: Ship[]; +} + +export function NKMissileEventLayer({ ships }: Props) { + const [selected, setSelected] = useState(null); + + const lineGeoJSON = useMemo(() => ({ + type: 'FeatureCollection' as const, + features: NK_MISSILE_EVENTS.map(ev => ({ + type: 'Feature' as const, + properties: { id: ev.id }, + geometry: { + type: 'LineString' as const, + coordinates: [[ev.launchLng, ev.launchLat], [ev.impactLng, ev.impactLat]], + }, + })), + }), []); + + const nearbyShips = useMemo(() => { + if (!selected) return []; + return ships.filter(s => distKm(s.lat, s.lng, selected.impactLat, selected.impactLng) < 50); + }, [selected, ships]); + + return ( + <> + {/* 궤적 라인 */} + + + + + {/* 발사 지점 (▲) */} + {NK_MISSILE_EVENTS.map(ev => { + const color = getMissileColor(ev.type); + const today = isToday(ev.date); + return ( + +
+ + + +
+
+ ); + })} + + {/* 낙하 지점 (✕ + 정보 라벨) */} + {NK_MISSILE_EVENTS.map(ev => { + const color = getMissileColor(ev.type); + const today = isToday(ev.date); + return ( + { e.originalEvent.stopPropagation(); setSelected(ev); }}> +
+ + + + + {today && ( + + + + + )} + +
+ {ev.date.slice(5)} {ev.time} ← {ev.launchNameKo} +
+
+
+ ); + })} + + {/* 낙하 지점 팝업 */} + {selected && (() => { + const color = getMissileColor(selected.type); + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="340px" className="gl-popup"> +
+
+ 🇰🇵 + 🚀 {selected.typeKo} +
+
+ + {selected.type} + + + {selected.date} {selected.time} KST + +
+
+
발사지 : {selected.launchNameKo}
+
발사시각 : {selected.time} KST
+
비행거리 : {selected.distanceKm.toLocaleString()} km
+
최고고도 : {selected.altitudeKm.toLocaleString()} km
+
비행시간 : {selected.flightMin}분
+
+
+ {selected.note} +
+
+ 낙하지점: {selected.impactLat.toFixed(2)}°N, {selected.impactLng.toFixed(2)}°E +
+ + {/* 인근 선박 */} +
+
0 ? '#f87171' : '#22c55e', marginBottom: 4 }}> + {nearbyShips.length > 0 + ? `⚠️ 낙하지점 50km 내 선박 ${nearbyShips.length}척` + : '✅ 낙하지점 50km 내 선박 없음'} +
+ {nearbyShips.slice(0, 5).map(s => { + const cat = getMarineTrafficCategory(s.typecode, s.category); + const d = distKm(s.lat, s.lng, selected.impactLat, selected.impactLng); + return ( +
+ + {s.name || s.mmsi} + {cat} + {d.toFixed(1)}km +
+ ); + })} + {nearbyShips.length > 5 && ( +
...외 {nearbyShips.length - 5}척
+ )} +
+
+
+ ); + })()} + + ); +} diff --git a/frontend/src/components/korea/NavWarningLayer.tsx b/frontend/src/components/korea/NavWarningLayer.tsx index 4015b62..ca8bec2 100644 --- a/frontend/src/components/korea/NavWarningLayer.tsx +++ b/frontend/src/components/korea/NavWarningLayer.tsx @@ -73,33 +73,45 @@ export function NavWarningLayer() { setSelected(null)} closeOnClick={false} anchor="bottom" maxWidth="320px" className="gl-popup"> -
+
+ color: '#fff', + padding: '4px 8px', + fontSize: 12, + fontWeight: 700, + margin: '-10px -10px 0', + borderRadius: '5px 5px 0 0', + }}> {selected.title}
-
+
+ color: '#fff', + padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700, + }}> {NW_LEVEL_LABEL[selected.level]} + padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700, + }}> {NW_ORG_LABEL[selected.org]} - + {selected.area}
-
+
{selected.description}
-
+
{t('navWarning.altitude')}: {selected.altitude}
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
{t('navWarning.source')}: {selected.source}
@@ -108,7 +120,7 @@ export function NavWarningLayer() { href="https://www.khoa.go.kr/nwb/mainPage.do?lang=ko" target="_blank" rel="noopener noreferrer" - className="mt-1.5 block text-[10px] text-kcg-accent underline" + style={{ display: 'block', marginTop: 6, fontSize: 10, color: '#3b82f6', textDecoration: 'underline' }} >{t('navWarning.khoaLink')}
diff --git a/frontend/src/components/korea/PortLayer.tsx b/frontend/src/components/korea/PortLayer.tsx new file mode 100644 index 0000000..d9d3ff4 --- /dev/null +++ b/frontend/src/components/korea/PortLayer.tsx @@ -0,0 +1,101 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { EAST_ASIA_PORTS } from '../../data/ports'; +import type { Port } from '../../data/ports'; + +const COUNTRY_STYLE: Record = { + KR: { color: '#3b82f6', flag: '🇰🇷', label: '한국' }, + CN: { color: '#ef4444', flag: '🇨🇳', label: '중국' }, + JP: { color: '#f472b6', flag: '🇯🇵', label: '일본' }, + KP: { color: '#f97316', flag: '🇰🇵', label: '북한' }, + TW: { color: '#10b981', flag: '🇹🇼', label: '대만' }, +}; + +function getStyle(p: Port) { + return COUNTRY_STYLE[p.country] || COUNTRY_STYLE.KR; +} + +function AnchorIcon({ color, size = 14 }: { color: string; size?: number }) { + return ( + + + + + + + ); +} + +export function PortLayer() { + const [selected, setSelected] = useState(null); + + return ( + <> + {EAST_ASIA_PORTS.map(p => { + const s = getStyle(p); + const size = p.type === 'major' ? 16 : 12; + return ( + { e.originalEvent.stopPropagation(); setSelected(p); }}> +
+ +
+ {p.nameKo.replace('항', '')} +
+
+
+ ); + })} + + {selected && (() => { + const s = getStyle(selected); + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ {s.flag} + ⚓ {selected.nameKo} +
+
+ + {selected.type === 'major' ? '주요항만' : '항만'} + + + {s.label} + +
+
+
항구 : {selected.nameKo}
+
영문 : {selected.name}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+ +
+
+ ); + })()} + + ); +} diff --git a/frontend/src/components/korea/WindFarmLayer.tsx b/frontend/src/components/korea/WindFarmLayer.tsx new file mode 100644 index 0000000..d2a0648 --- /dev/null +++ b/frontend/src/components/korea/WindFarmLayer.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { Marker, Popup } from 'react-map-gl/maplibre'; +import { KOREA_WIND_FARMS } from '../../data/windFarms'; +import type { WindFarm } from '../../data/windFarms'; + +const COLOR = '#00bcd4'; + +function WindTurbineIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + + + + + ); +} + +const STATUS_COLOR: Record = { + '운영중': '#22c55e', + '건설중': '#eab308', + '계획': '#64748b', +}; + +export function WindFarmLayer() { + const [selected, setSelected] = useState(null); + + return ( + <> + {KOREA_WIND_FARMS.map(wf => ( + { e.originalEvent.stopPropagation(); setSelected(wf); }}> +
+ +
+ {wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.name} +
+
+
+ ))} + + {selected && ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="280px" className="gl-popup"> +
+
+ 🌀 + {selected.name} +
+
+ + {selected.status} + + + 해상풍력 + + + {selected.region} + +
+
+
용량 : {selected.capacityMW} MW
+
터빈 : {selected.turbines}기
+ {selected.year &&
준공 : {selected.year}년
} +
지역 : {selected.region}
+
+
+ {selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E +
+
+
+ )} + + ); +} diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 7049302..4f5d71d 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -249,11 +249,13 @@ function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) { return (
-
+
{hasSPGlobal && (
setActiveTab('spglobal')} > @@ -261,8 +263,10 @@ function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
)}
setActiveTab('marinetraffic')} > diff --git a/frontend/src/data/govBuildings.ts b/frontend/src/data/govBuildings.ts new file mode 100644 index 0000000..c0218af --- /dev/null +++ b/frontend/src/data/govBuildings.ts @@ -0,0 +1,66 @@ +// ═══ China & Japan Government Buildings (OSINT Public Sources) ═══ + +export interface GovBuilding { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + country: 'CN' | 'JP'; + type: 'executive' | 'legislature' | 'military_hq' | 'intelligence' | 'foreign' | 'maritime' | 'defense'; + description: string; +} + +export const GOV_BUILDINGS: GovBuilding[] = [ + // ═══ 🇨🇳 중국 행정부 ═══ + { id: 'CN-G01', name: 'Zhongnanhai (CPC Central Committee)', nameKo: '중난하이 (중국공산당 중앙위원회)', lat: 39.9120, lng: 116.3820, country: 'CN', type: 'executive', description: '중국 최고 권력기관, 국가주석·총리 집무실' }, + { id: 'CN-G02', name: 'Great Hall of the People', nameKo: '인민대회당', lat: 39.9050, lng: 116.3880, country: 'CN', type: 'legislature', description: '전국인민대표대회(전인대) 회의장' }, + { id: 'CN-G03', name: 'State Council (Guowuyuan)', nameKo: '국무원', lat: 39.9130, lng: 116.3850, country: 'CN', type: 'executive', description: '중국 중앙정부 행정기관' }, + + // ═══ 🇨🇳 중국 국방/군사 본부 ═══ + { id: 'CN-G04', name: 'Central Military Commission (Bayi Building)', nameKo: '중앙군사위원회 (바이빌딩)', lat: 39.9080, lng: 116.3280, country: 'CN', type: 'military_hq', description: '중국 군 최고 지휘기관, 시진핑 군사위 주석' }, + { id: 'CN-G05', name: 'Ministry of National Defense', nameKo: '국방부', lat: 39.9090, lng: 116.3700, country: 'CN', type: 'defense', description: '국방 정책 수립, 대외 군사외교' }, + { id: 'CN-G06', name: 'PLA Navy Headquarters', nameKo: '인민해방군 해군사령부', lat: 39.9250, lng: 116.3550, country: 'CN', type: 'military_hq', description: '해군 작전지휘, 3대 함대 총괄' }, + { id: 'CN-G07', name: 'PLA Air Force Headquarters', nameKo: '인민해방군 공군사령부', lat: 39.8800, lng: 116.3600, country: 'CN', type: 'military_hq', description: '공군 작전지휘' }, + { id: 'CN-G08', name: 'PLA Rocket Force Headquarters', nameKo: '인민해방군 로켓군사령부', lat: 39.8700, lng: 116.3200, country: 'CN', type: 'military_hq', description: '전략미사일 부대 지휘, 핵전력 관리' }, + + // ═══ 🇨🇳 중국 정보/외교 기관 ═══ + { id: 'CN-G09', name: 'Ministry of State Security (MSS)', nameKo: '국가안전부', lat: 39.8960, lng: 116.3750, country: 'CN', type: 'intelligence', description: '대외 정보 수집, 방첩, 사이버 작전' }, + { id: 'CN-G10', name: 'Ministry of Foreign Affairs', nameKo: '외교부', lat: 39.9110, lng: 116.4050, country: 'CN', type: 'foreign', description: '외교 정책, 재외공관 총괄' }, + { id: 'CN-G11', name: 'Ministry of Public Security', nameKo: '공안부', lat: 39.8900, lng: 116.3800, country: 'CN', type: 'intelligence', description: '국내 치안, 경찰 총괄' }, + + // ═══ 🇨🇳 중국 해양 기관 ═══ + { id: 'CN-G12', name: 'China Coast Guard Headquarters', nameKo: '중국 해경총국', lat: 39.9300, lng: 116.3900, country: 'CN', type: 'maritime', description: '해양 법집행, 영해 순찰, 센카쿠/남중국해 작전' }, + { id: 'CN-G13', name: 'China Maritime Safety Administration', nameKo: '중국 해사국', lat: 39.9050, lng: 116.4200, country: 'CN', type: 'maritime', description: '해상교통 안전, 선박 검사, 항행 관리' }, + { id: 'CN-G14', name: 'State Oceanic Administration', nameKo: '국가해양국', lat: 39.9100, lng: 116.3950, country: 'CN', type: 'maritime', description: '해양 자원 관리, 해양 과학 연구' }, + + // ═══ 🇨🇳 중국 지방 주요 기관 ═══ + { id: 'CN-G15', name: 'Eastern Theater Command HQ (Nanjing)', nameKo: '동부전구 사령부 (난징)', lat: 32.0600, lng: 118.7800, country: 'CN', type: 'military_hq', description: '대만·동중국해 작전 총괄' }, + { id: 'CN-G16', name: 'Northern Theater Command HQ (Shenyang)', nameKo: '북부전구 사령부 (선양)', lat: 41.8100, lng: 123.4300, country: 'CN', type: 'military_hq', description: '한반도 방면 작전 총괄' }, + { id: 'CN-G17', name: 'Southern Theater Command HQ (Guangzhou)', nameKo: '남부전구 사령부 (광저우)', lat: 23.1300, lng: 113.2600, country: 'CN', type: 'military_hq', description: '남중국해 작전 총괄' }, + + // ═══ 🇯🇵 일본 행정부 ═══ + { id: 'JP-G01', name: 'Kantei (Prime Minister\'s Office)', nameKo: '총리관저 (간테이)', lat: 35.6764, lng: 139.7508, country: 'JP', type: 'executive', description: '내각총리대신 관저, 국가안전보장회의(NSC) 소재' }, + { id: 'JP-G02', name: 'National Diet Building', nameKo: '국회의사당', lat: 35.6760, lng: 139.7450, country: 'JP', type: 'legislature', description: '참의원·중의원 본회의장' }, + { id: 'JP-G03', name: 'Imperial Palace', nameKo: '황거 (고쿄)', lat: 35.6852, lng: 139.7528, country: 'JP', type: 'executive', description: '일본 천황 거처' }, + + // ═══ 🇯🇵 일본 국방/군사 본부 ═══ + { id: 'JP-G04', name: 'Ministry of Defense (Ichigaya)', nameKo: '방위성 (이치가야)', lat: 35.6942, lng: 139.7303, country: 'JP', type: 'defense', description: '자위대 최고 지휘기관, 방위대신 집무실' }, + { id: 'JP-G05', name: 'Joint Staff Office', nameKo: '통합막료감부', lat: 35.6940, lng: 139.7310, country: 'JP', type: 'military_hq', description: '육해공 자위대 통합 작전 지휘' }, + { id: 'JP-G06', name: 'MSDF Fleet Command (Yokosuka)', nameKo: '해상자위대 자위함대사령부 (요코스카)', lat: 35.2900, lng: 139.6700, country: 'JP', type: 'military_hq', description: '해상자위대 작전부대 총괄' }, + { id: 'JP-G07', name: 'ASDF Air Defense Command (Yokota)', nameKo: '항공자위대 항공총대사령부 (요코타)', lat: 35.7485, lng: 139.3485, country: 'JP', type: 'military_hq', description: '항공방위 작전 지휘, 미일 공동운용' }, + + // ═══ 🇯🇵 일본 정보/외교 기관 ═══ + { id: 'JP-G08', name: 'Cabinet Intelligence and Research Office', nameKo: '내각정보조사실 (CIRO)', lat: 35.6770, lng: 139.7500, country: 'JP', type: 'intelligence', description: '총리 직속 정보기관, 대외 정보 분석' }, + { id: 'JP-G09', name: 'Defense Intelligence Headquarters', nameKo: '방위성 정보본부 (DIH)', lat: 35.6550, lng: 139.4650, country: 'JP', type: 'intelligence', description: '군사정보 수집·분석, 위성정보 처리' }, + { id: 'JP-G10', name: 'Ministry of Foreign Affairs', nameKo: '외무성', lat: 35.6740, lng: 139.7490, country: 'JP', type: 'foreign', description: '외교 정책, 재외공관 총괄' }, + + // ═══ 🇯🇵 일본 해양 기관 ═══ + { id: 'JP-G11', name: 'Japan Coast Guard Headquarters', nameKo: '해상보안청 본청', lat: 35.6700, lng: 139.7600, country: 'JP', type: 'maritime', description: '해상 법집행, 영해 경비, 센카쿠 순찰' }, + { id: 'JP-G12', name: 'Japan Meteorological Agency / Hydrographic', nameKo: '해상보안청 해양정보부', lat: 35.6680, lng: 139.7620, country: 'JP', type: 'maritime', description: '해도 제작, 해양 관측, 항행 안전 정보' }, + + // ═══ 🇯🇵 일본 지방 주요 기관 ═══ + { id: 'JP-G13', name: 'GSDF Western Army HQ (Kumamoto)', nameKo: '육상자위대 서부방면대 사령부 (구마모토)', lat: 32.7900, lng: 130.7400, country: 'JP', type: 'military_hq', description: '남서제도 방위 총괄, 도서방위 부대' }, + { id: 'JP-G14', name: 'JCG 7th Region HQ (Kitakyushu)', nameKo: '제7관구 해상보안본부 (기타큐슈)', lat: 33.9000, lng: 130.8800, country: 'JP', type: 'maritime', description: '대한해협·동중국해 경비 총괄' }, + { id: 'JP-G15', name: 'JCG 11th Region HQ (Naha)', nameKo: '제11관구 해상보안본부 (나하)', lat: 26.2100, lng: 127.6700, country: 'JP', type: 'maritime', description: '센카쿠 열도 경비, 남서해역 순찰' }, +]; diff --git a/frontend/src/data/militaryBases.ts b/frontend/src/data/militaryBases.ts new file mode 100644 index 0000000..4ccd756 --- /dev/null +++ b/frontend/src/data/militaryBases.ts @@ -0,0 +1,90 @@ +// ═══ China & Japan Military Bases (OSINT Public Sources) ═══ +// Based on publicly available defense reports and satellite imagery analysis + +export interface MilitaryBase { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + country: 'CN' | 'JP' | 'KP' | 'TW'; + type: 'naval' | 'airforce' | 'army' | 'missile' | 'joint'; + description: string; +} + +export const MILITARY_BASES: MilitaryBase[] = [ + // ═══ 🇨🇳 중국 해군기지 ═══ + { id: 'CN-M01', name: 'Qingdao Naval Base (North Sea Fleet HQ)', nameKo: '칭다오 해군기지 (북해함대 사령부)', lat: 36.0671, lng: 120.3826, country: 'CN', type: 'naval', description: '북해함대 사령부, 항공모함 산둥호 모항' }, + { id: 'CN-M02', name: 'Ningbo/Zhoushan Naval Base (East Sea Fleet HQ)', nameKo: '닝보/저우산 해군기지 (동해함대 사령부)', lat: 29.9500, lng: 121.9500, country: 'CN', type: 'naval', description: '동해함대 사령부, 대만해협 작전' }, + { id: 'CN-M03', name: 'Zhanjiang Naval Base (South Sea Fleet HQ)', nameKo: '잔장 해군기지 (남해함대 사령부)', lat: 21.2000, lng: 110.4000, country: 'CN', type: 'naval', description: '남해함대 사령부, 남중국해 작전' }, + { id: 'CN-M04', name: 'Yulin Naval Base (Hainan)', nameKo: '위린 해군기지 (하이난)', lat: 18.2269, lng: 109.5531, country: 'CN', type: 'naval', description: '핵잠수함 기지, 지하 잠수함 동굴' }, + { id: 'CN-M05', name: 'Dalian Naval Shipyard', nameKo: '다롄 해군 조선소', lat: 38.9300, lng: 121.6200, country: 'CN', type: 'naval', description: '항공모함 건조, 푸젠호 건조지' }, + { id: 'CN-M06', name: 'Shanghai Jiangnan Shipyard', nameKo: '상하이 장난 조선소', lat: 31.3500, lng: 121.6000, country: 'CN', type: 'naval', description: '003형 항모 푸젠호 건조' }, + + // ═══ 🇨🇳 중국 공군기지 ═══ + { id: 'CN-M07', name: 'Dingxin Air Base', nameKo: '딩신 공군기지', lat: 40.3100, lng: 99.6700, country: 'CN', type: 'airforce', description: '전투기 시험평가 기지' }, + { id: 'CN-M08', name: 'Wuhan Wangjiatun Air Base', nameKo: '우한 왕자툰 공군기지', lat: 30.4900, lng: 114.3200, country: 'CN', type: 'airforce', description: 'J-20 스텔스 전투기 배치' }, + { id: 'CN-M09', name: 'Suixi Air Base', nameKo: '수이시 공군기지', lat: 21.4300, lng: 110.2200, country: 'CN', type: 'airforce', description: '남중국해 방공, J-11 배치' }, + { id: 'CN-M10', name: 'Longtian Air Base (Fuzhou)', nameKo: '룽톈 공군기지 (푸저우)', lat: 25.7500, lng: 119.6600, country: 'CN', type: 'airforce', description: '대만해협 전방기지' }, + + // ═══ 🇨🇳 중국 로켓군(미사일) 기지 ═══ + { id: 'CN-M11', name: 'PLA Rocket Force 61 Base', nameKo: '로켓군 61기지 (황산)', lat: 29.7100, lng: 118.3400, country: 'CN', type: 'missile', description: 'DF-21D 대함탄도미사일, 대만 방면' }, + { id: 'CN-M12', name: 'PLA Rocket Force 62 Base', nameKo: '로켓군 62기지 (쿤밍)', lat: 25.0200, lng: 102.6800, country: 'CN', type: 'missile', description: 'DF-26 중거리 미사일' }, + { id: 'CN-M13', name: 'PLA Rocket Force 63 Base', nameKo: '로켓군 63기지 (화이화)', lat: 27.5500, lng: 109.9500, country: 'CN', type: 'missile', description: 'ICBM 운용 부대' }, + + // ═══ 🇯🇵 일본 해상자위대 기지 ═══ + { id: 'JP-M01', name: 'Yokosuka Naval Base', nameKo: '요코스카 해군기지', lat: 35.2833, lng: 139.6667, country: 'JP', type: 'naval', description: '해상자위대 사령부, 미 7함대 모항' }, + { id: 'JP-M02', name: 'Sasebo Naval Base', nameKo: '사세보 해군기지', lat: 33.1583, lng: 129.7250, country: 'JP', type: 'naval', description: '서태평양 전진기지, 미 해군 공동사용' }, + { id: 'JP-M03', name: 'Kure Naval Base', nameKo: '쿠레 해군기지', lat: 34.2333, lng: 132.5500, country: 'JP', type: 'naval', description: '잠수함 부대 사령부' }, + { id: 'JP-M04', name: 'Maizuru Naval Base', nameKo: '마이즈루 해군기지', lat: 35.4667, lng: 135.3833, country: 'JP', type: 'naval', description: '동해 방면 경비' }, + { id: 'JP-M05', name: 'Ominato Naval Base', nameKo: '오미나토 해군기지', lat: 41.2500, lng: 141.1167, country: 'JP', type: 'naval', description: '북방 경비, 쓰가루 해협 방어' }, + + // ═══ 🇯🇵 일본 항공자위대 기지 ═══ + { id: 'JP-M06', name: 'Kadena Air Base (US)', nameKo: '가데나 공군기지 (미군)', lat: 26.3516, lng: 127.7681, country: 'JP', type: 'airforce', description: '미 태평양공군 최대기지, F-15 배치' }, + { id: 'JP-M07', name: 'Misawa Air Base', nameKo: '미사와 공군기지', lat: 40.7032, lng: 141.3686, country: 'JP', type: 'airforce', description: '미일 공동사용, F-16/F-35A 배치' }, + { id: 'JP-M08', name: 'Hyakuri Air Base', nameKo: '햐쿠리 공군기지', lat: 36.1811, lng: 140.4147, country: 'JP', type: 'airforce', description: '수도권 방공, F-2 전투기' }, + { id: 'JP-M09', name: 'Tsuiki Air Base', nameKo: '쓰이키 공군기지', lat: 33.6847, lng: 131.0408, country: 'JP', type: 'airforce', description: 'F-2 전투기 배치, 서부 방공' }, + { id: 'JP-M10', name: 'Naha Air Base', nameKo: '나하 공군기지', lat: 26.1956, lng: 127.6458, country: 'JP', type: 'airforce', description: '남서방면 항공혼성단, F-15J 배치' }, + { id: 'JP-M11', name: 'Komatsu Air Base', nameKo: '고마쓰 공군기지', lat: 36.3946, lng: 136.4068, country: 'JP', type: 'airforce', description: 'F-15J 배치, 동해 방공' }, + + // ═══ 🇯🇵 일본 육상자위대/미사일 기지 ═══ + { id: 'JP-M12', name: 'Camp Amami (Missile)', nameKo: '아마미 미사일기지', lat: 28.3800, lng: 129.4900, country: 'JP', type: 'missile', description: '12식 지대함미사일 배치, 남서제도 방어' }, + { id: 'JP-M13', name: 'Miyako Island Garrison', nameKo: '미야코지마 주둔지', lat: 24.7900, lng: 125.2800, country: 'JP', type: 'missile', description: '지대함/지대공 미사일 배치' }, + { id: 'JP-M14', name: 'Ishigaki Island Garrison', nameKo: '이시가키지마 주둔지', lat: 24.3400, lng: 124.1600, country: 'JP', type: 'missile', description: '지대함/지대공 미사일 배치, 센카쿠 근접' }, + + // ═══ 🇰🇵 북한 해군기지 ═══ + { id: 'KP-M01', name: 'Nampo Naval Base', nameKo: '남포 해군기지', lat: 38.7400, lng: 125.3800, country: 'KP', type: 'naval', description: '서해함대 사령부, 서해 주력기지' }, + { id: 'KP-M02', name: 'Wonsan Naval Base', nameKo: '원산 해군기지', lat: 39.1500, lng: 127.4500, country: 'KP', type: 'naval', description: '동해함대 주력기지, 잠수함 배치' }, + { id: 'KP-M03', name: 'Toejo-dong Submarine Base', nameKo: '토조동 잠수함기지', lat: 40.0100, lng: 128.1700, country: 'KP', type: 'naval', description: '신포급 SLBM 잠수함 기지' }, + { id: 'KP-M04', name: 'Mayang-do Naval Base', nameKo: '마양도 해군기지', lat: 40.0400, lng: 128.2100, country: 'KP', type: 'naval', description: '잠수함 수리·건조 시설' }, + + // ═══ 🇰🇵 북한 공군기지 ═══ + { id: 'KP-M05', name: 'Sunchon Air Base', nameKo: '순천 공군기지', lat: 39.4100, lng: 125.8900, country: 'KP', type: 'airforce', description: 'MiG-29 배치, 주력 전투기 기지' }, + { id: 'KP-M06', name: 'Onchon Air Base', nameKo: '온천 공군기지', lat: 38.9300, lng: 125.2700, country: 'KP', type: 'airforce', description: '평양 방공, MiG-21/23 배치' }, + { id: 'KP-M07', name: 'Wonsan-Kalma Air Base', nameKo: '원산갈마 공군기지', lat: 39.1700, lng: 127.4900, country: 'KP', type: 'airforce', description: '동해안 방공, 전투기/폭격기 배치' }, + + // ═══ 🇰🇵 북한 미사일 기지 ═══ + { id: 'KP-M08', name: 'Tongchang-ri (Sohae) Launch', nameKo: '동창리 (서해) 발사장', lat: 39.6600, lng: 124.7050, country: 'KP', type: 'missile', description: '장거리 미사일/위성 발사장' }, + { id: 'KP-M09', name: 'Musudan-ri (Tonghae) Launch', nameKo: '무수단리 (동해) 발사장', lat: 40.8560, lng: 129.6660, country: 'KP', type: 'missile', description: '동해 미사일 발사장' }, + { id: 'KP-M10', name: 'Yongbyon Nuclear Complex', nameKo: '영변 핵시설', lat: 39.7950, lng: 125.7550, country: 'KP', type: 'missile', description: '핵연료 재처리·우라늄 농축 시설' }, + { id: 'KP-M11', name: 'Punggye-ri Nuclear Test Site', nameKo: '풍계리 핵실험장', lat: 41.2810, lng: 129.0780, country: 'KP', type: 'missile', description: '지하 핵실험장 (6차례 핵실험)' }, + + // ═══ 🇹🇼 대만 해군기지 ═══ + { id: 'TW-M01', name: 'Zuoying Naval Base', nameKo: '쭤잉 해군기지', lat: 22.6900, lng: 120.2700, country: 'TW', type: 'naval', description: '해군사령부, 주력함대 모항. 기드급 구축함 배치' }, + { id: 'TW-M02', name: 'Keelung Naval Base', nameKo: '지룽 해군기지', lat: 25.1400, lng: 121.7500, country: 'TW', type: 'naval', description: '북부 함대기지, 대잠작전 거점' }, + { id: 'TW-M03', name: 'Suao Naval Base', nameKo: '쑤아오 해군기지', lat: 24.5800, lng: 121.8700, country: 'TW', type: 'naval', description: '동부 해군기지, 잠수함 배치' }, + { id: 'TW-M04', name: 'Magong Naval Base (Penghu)', nameKo: '마공 해군기지 (펑후)', lat: 23.5700, lng: 119.5800, country: 'TW', type: 'naval', description: '대만해협 전진기지, 해병대 주둔' }, + + // ═══ 🇹🇼 대만 공군기지 ═══ + { id: 'TW-M05', name: 'Hsinchu Air Base', nameKo: '신주 공군기지', lat: 24.8200, lng: 120.9400, country: 'TW', type: 'airforce', description: '미라주 2000 전투기 배치, 북부 방공' }, + { id: 'TW-M06', name: 'Taichung CCK Air Base', nameKo: '타이중 칭추안강 공군기지', lat: 24.2600, lng: 120.6200, country: 'TW', type: 'airforce', description: 'F-16V 배치, IDF 전투기' }, + { id: 'TW-M07', name: 'Tainan Air Base', nameKo: '타이난 공군기지', lat: 22.9500, lng: 120.2000, country: 'TW', type: 'airforce', description: 'IDF 경국호 전투기 배치' }, + { id: 'TW-M08', name: 'Hualien/Chiashan Air Base', nameKo: '화롄 쟈산 지하공군기지', lat: 24.0200, lng: 121.6200, country: 'TW', type: 'airforce', description: '산 속 지하 격납고, F-16 배치. 아시아 최대 지하기지' }, + { id: 'TW-M09', name: 'Pingtung Air Base', nameKo: '핑둥 공군기지', lat: 22.6700, lng: 120.4600, country: 'TW', type: 'airforce', description: 'P-3C 대잠초계기, E-2K 조기경보기 배치' }, + + // ═══ 🇹🇼 대만 미사일/방공 기지 ═══ + { id: 'TW-M10', name: 'Jiupeng Missile Test Range', nameKo: '주펑 미사일시험장', lat: 22.0700, lng: 120.8400, country: 'TW', type: 'missile', description: '중산과학연구원(NCSIST) 미사일 시험발사장' }, + { id: 'TW-M11', name: 'Leshan Radar Station', nameKo: '러산 레이더기지', lat: 24.2500, lng: 121.1900, country: 'TW', type: 'missile', description: '장거리 조기경보 레이더(PAVE PAWS), 미사일 감시' }, + { id: 'TW-M12', name: 'Kinmen Defense Command', nameKo: '진먼 방위사령부 (금문도)', lat: 24.4500, lng: 118.3800, country: 'TW', type: 'army', description: '중국 본토 2km 최전방, 해안포·지대함미사일 배치' }, + { id: 'TW-M13', name: 'Matsu Defense Command', nameKo: '마쭈 방위사령부', lat: 26.1600, lng: 119.9500, country: 'TW', type: 'army', description: '중국 푸젠성 인접 전진기지, 지대함미사일 배치' }, +]; diff --git a/frontend/src/data/nkLaunchSites.ts b/frontend/src/data/nkLaunchSites.ts new file mode 100644 index 0000000..893dc1a --- /dev/null +++ b/frontend/src/data/nkLaunchSites.ts @@ -0,0 +1,60 @@ +// ═══ North Korea Launch & Artillery Sites (OSINT) ═══ +// Based on: CSIS Beyond Parallel, 38 North, ROK JCS announcements, NTI + +export interface NKLaunchSite { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + type: 'icbm' | 'irbm' | 'srbm' | 'slbm' | 'cruise' | 'artillery' | 'mlrs'; + description: string; + recentUse?: string; +} + +const TYPE_META: Record = { + icbm: { label: 'ICBM 발사장', color: '#dc2626', icon: '🚀' }, + irbm: { label: 'IRBM/MRBM', color: '#ef4444', icon: '🚀' }, + srbm: { label: '단거리탄도미사일', color: '#f97316', icon: '🎯' }, + slbm: { label: 'SLBM 발사', color: '#3b82f6', icon: '🔱' }, + cruise: { label: '순항미사일', color: '#8b5cf6', icon: '✈️' }, + artillery: { label: '해안포/장사정포', color: '#eab308', icon: '💥' }, + mlrs: { label: '방사포(MLRS)', color: '#f59e0b', icon: '💥' }, +}; + +export { TYPE_META as NK_LAUNCH_TYPE_META }; + +export const NK_LAUNCH_SITES: NKLaunchSite[] = [ + // ═══ ICBM/장거리 발사장 ═══ + { id: 'NK-L01', name: 'Tongchang-ri (Sohae)', nameKo: '동창리 서해위성발사장', lat: 39.6600, lng: 124.7050, type: 'icbm', description: '서해 장거리 로켓/위성 발사장. 은하-3, 광명성 발사', recentUse: '2023 정찰위성 발사' }, + { id: 'NK-L02', name: 'Musudan-ri (Tonghae)', nameKo: '무수단리 동해위성발사장', lat: 40.8560, lng: 129.6660, type: 'icbm', description: '동해 장거리 미사일 발사장. 대포동 시리즈 발사', recentUse: '과거 대포동-2 발사' }, + + // ═══ 평양 일대 TEL 발사 (ICBM/IRBM) ═══ + { id: 'NK-L03', name: 'Sunan (Pyongyang Intl Airport)', nameKo: '순안 평양국제공항', lat: 39.2241, lng: 125.6700, type: 'icbm', description: '화성-17/18 ICBM TEL 발사. 공항 활주로 인근 발사', recentUse: '2023.7 화성-18 발사' }, + { id: 'NK-L04', name: 'Pyongyang area (Samdeok)', nameKo: '평양 삼덕 일대', lat: 39.0400, lng: 125.7800, type: 'irbm', description: '화성-12/14/15 발사. TEL 이동식 발사', recentUse: '2022 다수 발사' }, + + // ═══ 동해안 발사 지점 (단거리/순항) ═══ + { id: 'NK-L05', name: 'Wonsan area', nameKo: '원산 일대', lat: 39.1500, lng: 127.4500, type: 'srbm', description: '단거리탄도미사일(KN-23/25) 주요 발사지. 가장 빈번한 발사지역', recentUse: '2024 다수 SRBM 발사' }, + { id: 'NK-L06', name: 'Tongchon (Munchon)', nameKo: '통천 (문천)', lat: 38.9500, lng: 127.6800, type: 'srbm', description: '동해안 SRBM 발사지. KN-23 계열 발사', recentUse: '2022-2024 다수' }, + { id: 'NK-L07', name: 'Hamhung area', nameKo: '함흥 일대', lat: 39.9200, lng: 127.5400, type: 'srbm', description: '단거리미사일·방사포 발사지', recentUse: '2023 SRBM' }, + { id: 'NK-L08', name: 'Sinpo (Submarine Base)', nameKo: '신포 잠수함기지', lat: 40.0300, lng: 128.1800, type: 'slbm', description: 'SLBM(북극성) 수중발사 시험장. 신포급 잠수함 모항', recentUse: '2022.5 북극성 시험발사' }, + + // ═══ 서해안 발사 지점 ═══ + { id: 'NK-L09', name: 'Nampo area', nameKo: '남포 일대', lat: 38.7400, lng: 125.3800, type: 'cruise', description: '순항미사일 발사지. 서해 방면 발사', recentUse: '2024 순항미사일' }, + { id: 'NK-L10', name: 'Onchon area', nameKo: '온천 일대', lat: 38.9300, lng: 125.2700, type: 'cruise', description: '전략순항미사일(화살-1/2) 발사지', recentUse: '2023-2024 순항미사일' }, + + // ═══ 장사정포/방사포 진지 (NLL 위협) ═══ + { id: 'NK-L11', name: 'Kaemori (Yeonpyeong threat)', nameKo: '개머리 (연평도 위협)', lat: 37.9500, lng: 125.5200, type: 'artillery', description: '122mm 방사포·해안포. 연평도 직접 위협 진지. 2010년 연평도 포격 원점', recentUse: '2010.11 연평도 포격' }, + { id: 'NK-L12', name: 'Kangnyong area', nameKo: '강령 해안포 진지', lat: 37.9800, lng: 125.6500, type: 'artillery', description: 'NLL 인근 해안포·방사포 진지. 서해5도 위협', recentUse: '상시 전투배치' }, + { id: 'NK-L13', name: 'Haeju artillery zone', nameKo: '해주 장사정포 진지', lat: 38.0500, lng: 125.7300, type: 'artillery', description: '170mm 자주포·240mm 방사포. 서해5도·수도권 사정권', recentUse: '상시 전투배치' }, + { id: 'NK-L14', name: 'Hwanghae coastal batteries', nameKo: '황해 해안포 진지', lat: 38.3000, lng: 125.0500, type: 'artillery', description: '서해 해안포 밀집지역. 76.2mm·130mm 해안포', recentUse: '상시 전투배치' }, + + // ═══ DMZ 전방 장사정포 (수도권 위협) ═══ + { id: 'NK-L15', name: 'Kaesong forward artillery', nameKo: '개성 전방 포병진지', lat: 37.9700, lng: 126.5600, type: 'mlrs', description: '240mm 방사포, 서울 사정권. DMZ 최전방 배치', recentUse: '상시 전투배치' }, + { id: 'NK-L16', name: 'Kumgangsan area MLRS', nameKo: '금강산 방사포 진지', lat: 38.6500, lng: 128.1500, type: 'mlrs', description: '300mm 방사포(KN-09). 동해안 전방 진지', recentUse: '상시 전투배치' }, + { id: 'NK-L17', name: 'Chorwon forward positions', nameKo: '철원 전방 포병진지', lat: 38.4500, lng: 127.3000, type: 'mlrs', description: '170mm 자주포·240mm 방사포. 수도권 직접 위협', recentUse: '상시 전투배치' }, + + // ═══ 핵시설 ═══ + { id: 'NK-L18', name: 'Yongbyon Nuclear Complex', nameKo: '영변 핵단지', lat: 39.7950, lng: 125.7550, type: 'icbm', description: '5MW 원자로, 재처리시설, 우라늄 농축시설. 핵무기 원료 생산', recentUse: '가동 중 (IAEA 추정)' }, + { id: 'NK-L19', name: 'Punggye-ri Nuclear Test Site', nameKo: '풍계리 핵실험장', lat: 41.2810, lng: 129.0780, type: 'icbm', description: '만탑산 지하 핵실험장. 6차례 핵실험 수행. 2018 폭파 후 복구 정황', recentUse: '2017.9 6차 핵실험' }, +]; diff --git a/frontend/src/data/nkMissileEvents.ts b/frontend/src/data/nkMissileEvents.ts new file mode 100644 index 0000000..c265bb4 --- /dev/null +++ b/frontend/src/data/nkMissileEvents.ts @@ -0,0 +1,71 @@ +// ═══ North Korea Missile Launch Events 2026 (OSINT / ROK JCS) ═══ +// Based on: ROK Joint Chiefs of Staff announcements, NHK, 38 North, CSIS + +export interface NKMissileEvent { + id: string; + date: string; + time: string; // KST 발사 시각 + type: string; + typeKo: string; + launchLat: number; + launchLng: number; + launchName: string; + launchNameKo: string; + impactLat: number; + impactLng: number; + distanceKm: number; + altitudeKm: number; + flightMin: number; // 비행시간(분) + note: string; +} + +export const NK_MISSILE_EVENTS: NKMissileEvent[] = [ + { + id: 'NKM-2026-01', + date: '2026-01-08', + time: '07:15', + type: 'Hwasong-18 (ICBM)', + typeKo: '화성-18 (ICBM)', + launchLat: 39.22, launchLng: 125.67, + launchName: 'Sunan (Pyongyang)', launchNameKo: '순안 (평양)', + impactLat: 40.50, impactLng: 134.50, + distanceKm: 1200, altitudeKm: 6500, flightMin: 73, + note: '고체연료 ICBM 3단, 고각발사. 일본 EEZ 내 낙하. 합참 08:28 발표', + }, + { + id: 'NKM-2026-02', + date: '2026-02-14', + time: '11:03', + type: 'Hwasong-12 (IRBM)', + typeKo: '화성-12 (IRBM)', + launchLat: 39.04, launchLng: 125.78, + launchName: 'Pyongyang area', launchNameKo: '평양 일대', + impactLat: 39.80, impactLng: 136.20, + distanceKm: 4500, altitudeKm: 980, flightMin: 30, + note: '중거리 탄도미사일, 정상각 발사. 일본 상공 통과 추정. 합참 11:35 발표', + }, + { + id: 'NKM-2026-03', + date: '2026-03-12', + time: '06:42', + type: 'Hwasong-11 (KN-23) x2', + typeKo: '화성-11 (KN-23) 2발', + launchLat: 39.15, launchLng: 127.45, + launchName: 'Wonsan area', launchNameKo: '원산 일대', + impactLat: 38.60, impactLng: 131.80, + distanceKm: 450, altitudeKm: 60, flightMin: 8, + note: '단거리 2발 연속 발사 (06:42, 06:48). 저고도 변칙기동, 이스칸데르형. 동해 낙하', + }, + { + id: 'NKM-2026-04', + date: '2026-03-18', + time: '16:30', + type: 'KN-25 (SRBM) x3', + typeKo: 'KN-25 (단거리) 3발', + launchLat: 39.15, launchLng: 127.45, + launchName: 'Wonsan area', launchNameKo: '원산 일대', + impactLat: 38.90, impactLng: 131.50, + distanceKm: 380, altitudeKm: 90, flightMin: 7, + note: '초대형 방사포 3발 연속 발사. 동해상 낙하. 합참 17:05 발표', + }, +]; diff --git a/frontend/src/data/ports.ts b/frontend/src/data/ports.ts new file mode 100644 index 0000000..7e44cfc --- /dev/null +++ b/frontend/src/data/ports.ts @@ -0,0 +1,76 @@ +// ═══ East Asia Major Ports ═══ + +export interface Port { + id: string; + name: string; + nameKo: string; + lat: number; + lng: number; + country: 'KR' | 'CN' | 'JP' | 'KP' | 'TW'; + type: 'major' | 'medium'; +} + +export const EAST_ASIA_PORTS: Port[] = [ + // ═══ 🇰🇷 한국 ═══ + { id: 'KR-BSN', name: 'Busan', nameKo: '부산항', lat: 35.1028, lng: 129.0403, country: 'KR', type: 'major' }, + { id: 'KR-ICN', name: 'Incheon', nameKo: '인천항', lat: 37.4500, lng: 126.5933, country: 'KR', type: 'major' }, + { id: 'KR-GWY', name: 'Gwangyang', nameKo: '광양항', lat: 34.9167, lng: 127.6833, country: 'KR', type: 'major' }, + { id: 'KR-USN', name: 'Ulsan', nameKo: '울산항', lat: 35.5000, lng: 129.3833, country: 'KR', type: 'major' }, + { id: 'KR-PTK', name: 'Pyeongtaek-Dangjin', nameKo: '평택당진항', lat: 36.9667, lng: 126.8167, country: 'KR', type: 'major' }, + { id: 'KR-MKP', name: 'Mokpo', nameKo: '목포항', lat: 34.7833, lng: 126.3833, country: 'KR', type: 'medium' }, + { id: 'KR-YSU', name: 'Yeosu', nameKo: '여수항', lat: 34.7333, lng: 127.7667, country: 'KR', type: 'medium' }, + { id: 'KR-MSN', name: 'Masan', nameKo: '마산항', lat: 35.1833, lng: 128.5667, country: 'KR', type: 'medium' }, + { id: 'KR-POH', name: 'Pohang', nameKo: '포항항', lat: 36.0333, lng: 129.3833, country: 'KR', type: 'medium' }, + { id: 'KR-DH', name: 'Donghae', nameKo: '동해항', lat: 37.5000, lng: 129.1167, country: 'KR', type: 'medium' }, + { id: 'KR-JEJ', name: 'Jeju', nameKo: '제주항', lat: 33.5167, lng: 126.5333, country: 'KR', type: 'medium' }, + + // ═══ 🇨🇳 중국 ═══ + { id: 'CN-SHA', name: 'Shanghai', nameKo: '상하이항', lat: 31.3600, lng: 121.6200, country: 'CN', type: 'major' }, + { id: 'CN-NGB', name: 'Ningbo-Zhoushan', nameKo: '닝보저우산항', lat: 29.8700, lng: 121.8800, country: 'CN', type: 'major' }, + { id: 'CN-SZX', name: 'Shenzhen', nameKo: '선전항', lat: 22.4800, lng: 114.0700, country: 'CN', type: 'major' }, + { id: 'CN-GZU', name: 'Guangzhou', nameKo: '광저우항', lat: 23.0800, lng: 113.5800, country: 'CN', type: 'major' }, + { id: 'CN-QDO', name: 'Qingdao', nameKo: '칭다오항', lat: 36.0500, lng: 120.3300, country: 'CN', type: 'major' }, + { id: 'CN-TSN', name: 'Tianjin', nameKo: '톈진항', lat: 38.9800, lng: 117.7300, country: 'CN', type: 'major' }, + { id: 'CN-DLC', name: 'Dalian', nameKo: '다롄항', lat: 38.9200, lng: 121.6500, country: 'CN', type: 'major' }, + { id: 'CN-XMN', name: 'Xiamen', nameKo: '샤먼항', lat: 24.4500, lng: 118.0800, country: 'CN', type: 'major' }, + { id: 'CN-YTI', name: 'Yantian', nameKo: '옌톈항', lat: 22.5700, lng: 114.2700, country: 'CN', type: 'major' }, + { id: 'CN-LYG', name: 'Lianyungang', nameKo: '롄윈강항', lat: 34.7400, lng: 119.4400, country: 'CN', type: 'medium' }, + { id: 'CN-YNT', name: 'Yantai', nameKo: '옌타이항', lat: 37.5400, lng: 121.3900, country: 'CN', type: 'medium' }, + { id: 'CN-WEH', name: 'Weihai', nameKo: '웨이하이항', lat: 37.5000, lng: 122.1200, country: 'CN', type: 'medium' }, + { id: 'CN-RZH', name: 'Rizhao', nameKo: '르자오항', lat: 35.3800, lng: 119.5500, country: 'CN', type: 'medium' }, + { id: 'CN-FZU', name: 'Fuzhou', nameKo: '푸저우항', lat: 25.9800, lng: 119.4400, country: 'CN', type: 'medium' }, + + // ═══ 🇯🇵 일본 ═══ + { id: 'JP-TYO', name: 'Tokyo', nameKo: '도쿄항', lat: 35.6200, lng: 139.7700, country: 'JP', type: 'major' }, + { id: 'JP-YOK', name: 'Yokohama', nameKo: '요코하마항', lat: 35.4500, lng: 139.6500, country: 'JP', type: 'major' }, + { id: 'JP-NGY', name: 'Nagoya', nameKo: '나고야항', lat: 35.0800, lng: 136.8800, country: 'JP', type: 'major' }, + { id: 'JP-OSA', name: 'Osaka', nameKo: '오사카항', lat: 34.6500, lng: 135.4300, country: 'JP', type: 'major' }, + { id: 'JP-KOB', name: 'Kobe', nameKo: '고베항', lat: 34.6800, lng: 135.1900, country: 'JP', type: 'major' }, + { id: 'JP-KIT', name: 'Kitakyushu', nameKo: '기타큐슈항', lat: 33.9500, lng: 130.9400, country: 'JP', type: 'major' }, + { id: 'JP-HKT', name: 'Hakata', nameKo: '하카타항', lat: 33.6000, lng: 130.4000, country: 'JP', type: 'major' }, + { id: 'JP-HSM', name: 'Hiroshima', nameKo: '히로시마항', lat: 34.3500, lng: 132.4500, country: 'JP', type: 'medium' }, + { id: 'JP-NGS', name: 'Nagasaki', nameKo: '나가사키항', lat: 32.7400, lng: 129.8700, country: 'JP', type: 'medium' }, + { id: 'JP-KGS', name: 'Kagoshima', nameKo: '가고시마항', lat: 31.5800, lng: 130.5600, country: 'JP', type: 'medium' }, + { id: 'JP-NII', name: 'Niigata', nameKo: '니가타항', lat: 37.9500, lng: 139.0600, country: 'JP', type: 'medium' }, + { id: 'JP-SAS', name: 'Sasebo', nameKo: '사세보항', lat: 33.1600, lng: 129.7200, country: 'JP', type: 'medium' }, + { id: 'JP-SMZ', name: 'Shimizu', nameKo: '시미즈항', lat: 35.0200, lng: 138.5000, country: 'JP', type: 'medium' }, + + // ═══ 🇰🇵 북한 ═══ + { id: 'KP-NAM', name: 'Nampo', nameKo: '남포항', lat: 38.7370, lng: 125.3950, country: 'KP', type: 'major' }, + { id: 'KP-WON', name: 'Wonsan', nameKo: '원산항', lat: 39.1530, lng: 127.4430, country: 'KP', type: 'major' }, + { id: 'KP-CHO', name: 'Chongjin', nameKo: '청진항', lat: 41.7900, lng: 129.7900, country: 'KP', type: 'major' }, + { id: 'KP-HNG', name: 'Hungnam', nameKo: '흥남항', lat: 39.8300, lng: 127.6200, country: 'KP', type: 'major' }, + { id: 'KP-HJU', name: 'Haeju', nameKo: '해주항', lat: 38.0300, lng: 125.7100, country: 'KP', type: 'medium' }, + { id: 'KP-SNP', name: 'Sinpo', nameKo: '신포항', lat: 40.0300, lng: 128.1800, country: 'KP', type: 'medium' }, + { id: 'KP-KSG', name: 'Kimchaek (Songjin)', nameKo: '김책항 (성진)', lat: 40.6700, lng: 129.1900, country: 'KP', type: 'medium' }, + { id: 'KP-RSN', name: 'Rajin-Sonbong', nameKo: '나진선봉항', lat: 42.2500, lng: 130.2900, country: 'KP', type: 'medium' }, + + // ═══ 🇹🇼 대만 ═══ + { id: 'TW-KHH', name: 'Kaohsiung', nameKo: '가오슝항', lat: 22.6100, lng: 120.2800, country: 'TW', type: 'major' }, + { id: 'TW-KEL', name: 'Keelung', nameKo: '지룽항', lat: 25.1300, lng: 121.7400, country: 'TW', type: 'major' }, + { id: 'TW-TXG', name: 'Taichung', nameKo: '타이중항', lat: 24.2900, lng: 120.5100, country: 'TW', type: 'major' }, + { id: 'TW-HUA', name: 'Hualien', nameKo: '화롄항', lat: 23.9800, lng: 121.6300, country: 'TW', type: 'medium' }, + { id: 'TW-SUO', name: 'Suao', nameKo: '쑤아오항', lat: 24.5900, lng: 121.8600, country: 'TW', type: 'medium' }, + { id: 'TW-ANP', name: 'Anping (Tainan)', nameKo: '안핑항 (타이난)', lat: 22.9800, lng: 120.1600, country: 'TW', type: 'medium' }, + { id: 'TW-MZG', name: 'Magong (Penghu)', nameKo: '마공항 (펑후)', lat: 23.5600, lng: 119.5700, country: 'TW', type: 'medium' }, +]; diff --git a/frontend/src/data/windFarms.ts b/frontend/src/data/windFarms.ts new file mode 100644 index 0000000..5679b92 --- /dev/null +++ b/frontend/src/data/windFarms.ts @@ -0,0 +1,36 @@ +// ═══ 한국 해상풍력발전단지 데이터 ═══ +// Source: https://kwonpolice.tistory.com/137 + +export interface WindFarm { + id: string; + name: string; + region: string; + lat: number; + lng: number; + turbines: number; + capacityMW: number; + status: '운영중' | '건설중' | '계획'; + year?: number; +} + +export const KOREA_WIND_FARMS: WindFarm[] = [ + // ═══ 제주 ═══ + { id: 'wf-01', name: '월정 해상풍력 1호', region: '제주', lat: 33.575, lng: 126.79, turbines: 1, capacityMW: 3, status: '운영중', year: 2017 }, + { id: 'wf-02', name: '월정 해상풍력 2호', region: '제주', lat: 33.575, lng: 126.78, turbines: 1, capacityMW: 3, status: '운영중', year: 2017 }, + { id: 'wf-03', name: '탐라 해상풍력단지', region: '제주', lat: 33.360, lng: 126.18, turbines: 10, capacityMW: 30, status: '운영중', year: 2017 }, + + // ═══ 경주 ═══ + { id: 'wf-04', name: 'GS천북 해상풍력', region: '경주', lat: 35.937, lng: 129.28, turbines: 3, capacityMW: 9, status: '운영중', year: 2018 }, + + // ═══ 군산 ═══ + { id: 'wf-05', name: '군산 해상풍력', region: '군산', lat: 35.972, lng: 126.51, turbines: 1, capacityMW: 3, status: '운영중', year: 2016 }, + + // ═══ 양산 ═══ + { id: 'wf-06', name: '어곡 해상풍력', region: '양산', lat: 35.426, lng: 128.99, turbines: 1, capacityMW: 3, status: '운영중', year: 2019 }, + + // ═══ 영광 ═══ + { id: 'wf-07', name: '영광 해상풍력단지', region: '영광', lat: 35.260, lng: 126.325, turbines: 15, capacityMW: 35, status: '운영중', year: 2020 }, + + // ═══ 서남해 실증단지 (부안/영광) ═══ + { id: 'wf-08', name: '서남해 해상풍력 실증단지', region: '부안', lat: 35.480, lng: 126.315, turbines: 20, capacityMW: 60, status: '운영중', year: 2019 }, +]; diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index f7952af..7d09811 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -13,6 +13,7 @@ interface UseKoreaDataArgs { isLive: boolean; hiddenAcCategories: Set; hiddenShipCategories: Set; + hiddenNationalities: Set; refreshKey: number; } @@ -26,6 +27,7 @@ interface UseKoreaDataResult { koreaKoreanShips: Ship[]; koreaChineseShips: Ship[]; shipsByCategory: Record; + shipsByNationality: Record; aircraftByCategory: Record; militaryCount: number; } @@ -33,11 +35,22 @@ interface UseKoreaDataResult { const SHIP_POLL_INTERVAL = 300_000; // 5 min const SHIP_STALE_MS = 3_600_000; // 60 min +/** Map flag code to nationality group key */ +export function getNationalityGroup(flag?: string): string { + if (!flag) return 'unclassified'; + if (flag === 'KR') return 'KR'; + if (flag === 'CN') return 'CN'; + if (flag === 'KP') return 'KP'; + if (flag === 'JP') return 'JP'; + return 'unclassified'; +} + export function useKoreaData({ currentTime, isLive, hiddenAcCategories, hiddenShipCategories, + hiddenNationalities, refreshKey, }: UseKoreaDataArgs): UseKoreaDataResult { const [baseAircraftKorea, setBaseAircraftKorea] = useState([]); @@ -144,8 +157,11 @@ export function useKoreaData({ ); const visibleShips = useMemo( - () => ships.filter(s => !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category))), - [ships, hiddenShipCategories], + () => ships.filter(s => + !hiddenShipCategories.has(getMarineTrafficCategory(s.typecode, s.category)) + && !hiddenNationalities.has(getNationalityGroup(s.flag)), + ), + [ships, hiddenShipCategories, hiddenNationalities], ); // Korea region stats @@ -161,6 +177,15 @@ export function useKoreaData({ return counts; }, [ships]); + const shipsByNationality = useMemo(() => { + const counts: Record = {}; + for (const s of ships) { + const nat = getNationalityGroup(s.flag); + counts[nat] = (counts[nat] || 0) + 1; + } + return counts; + }, [ships]); + // Korea aircraft stats const aircraftByCategory = useMemo(() => { const counts: Record = {}; @@ -185,6 +210,7 @@ export function useKoreaData({ koreaKoreanShips, koreaChineseShips, shipsByCategory, + shipsByNationality, aircraftByCategory, militaryCount, }; diff --git a/frontend/src/i18n/locales/en/ships.json b/frontend/src/i18n/locales/en/ships.json index f042d7e..8b10eac 100644 --- a/frontend/src/i18n/locales/en/ships.json +++ b/frontend/src/i18n/locales/en/ships.json @@ -105,8 +105,8 @@ "loading": "Loading..." }, "status": { - "anchored": "Anchored", - "underway": "Underway", + "anchored": "Anch", + "underway": "Under", "moored": "Moored", "total": "Total" }, diff --git a/frontend/src/services/airports.ts b/frontend/src/services/airports.ts index f52a211..53b8deb 100644 --- a/frontend/src/services/airports.ts +++ b/frontend/src/services/airports.ts @@ -1,5 +1,5 @@ -// ═══ Korean Airports Data ═══ -// International + Domestic merged into single markers +// ═══ East Asia Airports Data ═══ +// Korea + China + Japan airports export interface KoreanAirport { id: string; // IATA code @@ -11,28 +11,89 @@ export interface KoreanAirport { type: 'international' | 'domestic' | 'military'; intl: boolean; // has international flights domestic: boolean; // has domestic flights + country?: 'KR' | 'CN' | 'JP' | 'KP' | 'TW'; } export const KOREAN_AIRPORTS: KoreanAirport[] = [ - // ═══ 주요 국제공항 ═══ - { id: 'ICN', icao: 'RKSI', name: 'Incheon Intl', nameKo: '인천국제공항', lat: 37.4602, lng: 126.4407, type: 'international', intl: true, domestic: true }, - { id: 'GMP', icao: 'RKSS', name: 'Gimpo Intl', nameKo: '김포국제공항', lat: 37.5583, lng: 126.7906, type: 'international', intl: true, domestic: true }, - { id: 'PUS', icao: 'RKPK', name: 'Gimhae Intl', nameKo: '김해국제공항', lat: 35.1795, lng: 128.9382, type: 'international', intl: true, domestic: true }, - { id: 'CJU', icao: 'RKPC', name: 'Jeju Intl', nameKo: '제주국제공항', lat: 33.5113, lng: 126.4929, type: 'international', intl: true, domestic: true }, - { id: 'TAE', icao: 'RKTN', name: 'Daegu Intl', nameKo: '대구국제공항', lat: 35.8941, lng: 128.6589, type: 'international', intl: true, domestic: true }, - { id: 'CJJ', icao: 'RKTU', name: 'Cheongju Intl', nameKo: '청주국제공항', lat: 36.7166, lng: 127.4991, type: 'international', intl: true, domestic: true }, - { id: 'MWX', icao: 'RKJB', name: 'Muan Intl', nameKo: '무안국제공항', lat: 34.9914, lng: 126.3828, type: 'international', intl: true, domestic: true }, + // ═══ 🇰🇷 한국 주요 국제공항 ═══ + { id: 'ICN', icao: 'RKSI', name: 'Incheon Intl', nameKo: '인천국제공항', lat: 37.4602, lng: 126.4407, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'GMP', icao: 'RKSS', name: 'Gimpo Intl', nameKo: '김포국제공항', lat: 37.5583, lng: 126.7906, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'PUS', icao: 'RKPK', name: 'Gimhae Intl', nameKo: '김해국제공항', lat: 35.1795, lng: 128.9382, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'CJU', icao: 'RKPC', name: 'Jeju Intl', nameKo: '제주국제공항', lat: 33.5113, lng: 126.4929, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'TAE', icao: 'RKTN', name: 'Daegu Intl', nameKo: '대구국제공항', lat: 35.8941, lng: 128.6589, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'CJJ', icao: 'RKTU', name: 'Cheongju Intl', nameKo: '청주국제공항', lat: 36.7166, lng: 127.4991, type: 'international', intl: true, domestic: true, country: 'KR' }, + { id: 'MWX', icao: 'RKJB', name: 'Muan Intl', nameKo: '무안국제공항', lat: 34.9914, lng: 126.3828, type: 'international', intl: true, domestic: true, country: 'KR' }, - // ═══ 국내선 공항 ═══ - { id: 'KWJ', icao: 'RKJJ', name: 'Gwangju', nameKo: '광주공항', lat: 35.1264, lng: 126.8089, type: 'domestic', intl: false, domestic: true }, - { id: 'RSU', icao: 'RKJY', name: 'Yeosu', nameKo: '여수공항', lat: 34.8423, lng: 127.6170, type: 'domestic', intl: false, domestic: true }, - { id: 'USN', icao: 'RKPU', name: 'Ulsan', nameKo: '울산공항', lat: 35.5935, lng: 129.3519, type: 'domestic', intl: false, domestic: true }, - { id: 'KPO', icao: 'RKTH', name: 'Pohang', nameKo: '포항공항', lat: 35.9878, lng: 129.4205, type: 'domestic', intl: false, domestic: true }, - { id: 'HIN', icao: 'RKPS', name: 'Sacheon', nameKo: '사천공항', lat: 35.0886, lng: 128.0702, type: 'domestic', intl: false, domestic: true }, - { id: 'WJU', icao: 'RKNW', name: 'Wonju', nameKo: '원주공항', lat: 37.4381, lng: 127.9604, type: 'domestic', intl: false, domestic: true }, - { id: 'KUV', icao: 'RKJK', name: 'Gunsan', nameKo: '군산공항', lat: 35.9038, lng: 126.6158, type: 'domestic', intl: false, domestic: true }, - { id: 'YNY', icao: 'RKNY', name: 'Yangyang Intl', nameKo: '양양국제공항', lat: 38.0613, lng: 128.6690, type: 'international', intl: true, domestic: true }, + // ═══ 🇰🇷 한국 국내선 공항 ═══ + { id: 'KWJ', icao: 'RKJJ', name: 'Gwangju', nameKo: '광주공항', lat: 35.1264, lng: 126.8089, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'RSU', icao: 'RKJY', name: 'Yeosu', nameKo: '여수공항', lat: 34.8423, lng: 127.6170, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'USN', icao: 'RKPU', name: 'Ulsan', nameKo: '울산공항', lat: 35.5935, lng: 129.3519, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'KPO', icao: 'RKTH', name: 'Pohang', nameKo: '포항공항', lat: 35.9878, lng: 129.4205, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'HIN', icao: 'RKPS', name: 'Sacheon', nameKo: '사천공항', lat: 35.0886, lng: 128.0702, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'WJU', icao: 'RKNW', name: 'Wonju', nameKo: '원주공항', lat: 37.4381, lng: 127.9604, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'KUV', icao: 'RKJK', name: 'Gunsan', nameKo: '군산공항', lat: 35.9038, lng: 126.6158, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + { id: 'YNY', icao: 'RKNY', name: 'Yangyang Intl', nameKo: '양양국제공항', lat: 38.0613, lng: 128.6690, type: 'international', intl: true, domestic: true, country: 'KR' }, - // ═══ 도서 공항 ═══ - { id: 'JDG', icao: 'RKPD', name: 'Jeongseok (Ulleungdo)', nameKo: '울릉공항', lat: 37.5200, lng: 130.8980, type: 'domestic', intl: false, domestic: true }, + // ═══ 🇰🇷 한국 도서 공항 ═══ + { id: 'JDG', icao: 'RKPD', name: 'Jeongseok (Ulleungdo)', nameKo: '울릉공항', lat: 37.5200, lng: 130.8980, type: 'domestic', intl: false, domestic: true, country: 'KR' }, + + // ═══ 🇨🇳 중국 주요 공항 ═══ + { id: 'PEK', icao: 'ZBAA', name: 'Beijing Capital Intl', nameKo: '베이징 서우두국제공항', lat: 40.0799, lng: 116.6031, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'PKX', icao: 'ZBAD', name: 'Beijing Daxing Intl', nameKo: '베이징 다싱국제공항', lat: 39.5098, lng: 116.4105, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'PVG', icao: 'ZSPD', name: 'Shanghai Pudong Intl', nameKo: '상하이 푸둥국제공항', lat: 31.1434, lng: 121.8052, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'SHA', icao: 'ZSSS', name: 'Shanghai Hongqiao Intl', nameKo: '상하이 홍차오국제공항', lat: 31.1979, lng: 121.3363, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'CAN', icao: 'ZGGG', name: 'Guangzhou Baiyun Intl', nameKo: '광저우 바이윈국제공항', lat: 23.3924, lng: 113.2988, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'SZX', icao: 'ZGSZ', name: 'Shenzhen Baoan Intl', nameKo: '선전 바오안국제공항', lat: 22.6393, lng: 113.8107, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'CTU', icao: 'ZUUU', name: 'Chengdu Shuangliu Intl', nameKo: '청두 솽류국제공항', lat: 30.5785, lng: 103.9471, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'CKG', icao: 'ZUCK', name: 'Chongqing Jiangbei Intl', nameKo: '충칭 장베이국제공항', lat: 29.7192, lng: 106.6417, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'HGH', icao: 'ZSHC', name: 'Hangzhou Xiaoshan Intl', nameKo: '항저우 샤오산국제공항', lat: 30.2295, lng: 120.4344, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'NKG', icao: 'ZSNJ', name: 'Nanjing Lukou Intl', nameKo: '난징 루커우국제공항', lat: 31.7420, lng: 118.8620, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'XIY', icao: 'ZLXY', name: "Xi'an Xianyang Intl", nameKo: '시안 셴양국제공항', lat: 34.4471, lng: 108.7516, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'WUH', icao: 'ZHHH', name: 'Wuhan Tianhe Intl', nameKo: '우한 톈허국제공항', lat: 30.7838, lng: 114.2081, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'KMG', icao: 'ZPPP', name: 'Kunming Changshui Intl', nameKo: '쿤밍 창수이국제공항', lat: 25.1019, lng: 102.9292, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'TSN', icao: 'ZBTJ', name: 'Tianjin Binhai Intl', nameKo: '톈진 빈하이국제공항', lat: 39.1244, lng: 117.3462, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'SHE', icao: 'ZYTX', name: 'Shenyang Taoxian Intl', nameKo: '선양 타오셴국제공항', lat: 41.6398, lng: 123.4833, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'DLC', icao: 'ZYTL', name: 'Dalian Zhoushuizi Intl', nameKo: '다롄 저우수이쯔국제공항', lat: 38.9657, lng: 121.5386, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'TAO', icao: 'ZSQD', name: 'Qingdao Jiaodong Intl', nameKo: '칭다오 자오둥국제공항', lat: 36.3711, lng: 120.0875, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'XMN', icao: 'ZSAM', name: 'Xiamen Gaoqi Intl', nameKo: '샤먼 가오치국제공항', lat: 24.5440, lng: 118.1278, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'HRB', icao: 'ZYHB', name: 'Harbin Taiping Intl', nameKo: '하얼빈 타이핑국제공항', lat: 45.6234, lng: 126.2503, type: 'international', intl: true, domestic: true, country: 'CN' }, + { id: 'CGQ', icao: 'ZYCC', name: 'Changchun Longjia Intl', nameKo: '창춘 룽자국제공항', lat: 43.9962, lng: 125.6853, type: 'international', intl: true, domestic: true, country: 'CN' }, + + // ═══ 🇯🇵 일본 주요 공항 ═══ + { id: 'NRT', icao: 'RJAA', name: 'Narita Intl', nameKo: '나리타국제공항', lat: 35.7647, lng: 140.3864, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'HND', icao: 'RJTT', name: 'Tokyo Haneda', nameKo: '하네다공항', lat: 35.5533, lng: 139.7811, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'KIX', icao: 'RJBB', name: 'Kansai Intl', nameKo: '간사이국제공항', lat: 34.4320, lng: 135.2304, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'ITM', icao: 'RJOO', name: 'Osaka Itami', nameKo: '오사카 이타미공항', lat: 34.7855, lng: 135.4380, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'NGO', icao: 'RJGG', name: 'Chubu Centrair Intl', nameKo: '주부국제공항', lat: 34.8585, lng: 136.8125, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'FUK', icao: 'RJFF', name: 'Fukuoka', nameKo: '후쿠오카공항', lat: 33.5859, lng: 130.4511, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'CTS', icao: 'RJCC', name: 'New Chitose', nameKo: '신치토세공항', lat: 42.7752, lng: 141.6925, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'OKA', icao: 'ROAH', name: 'Naha', nameKo: '나하공항', lat: 26.1958, lng: 127.6459, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'HIJ', icao: 'RJOA', name: 'Hiroshima', nameKo: '히로시마공항', lat: 34.4361, lng: 132.9195, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'SDJ', icao: 'RJSS', name: 'Sendai', nameKo: '센다이공항', lat: 38.1397, lng: 140.9170, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'KOJ', icao: 'RJFK', name: 'Kagoshima', nameKo: '가고시마공항', lat: 31.8034, lng: 130.7195, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'KMQ', icao: 'RJNK', name: 'Komatsu', nameKo: '고마쓰공항', lat: 36.3946, lng: 136.4068, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'NGS', icao: 'RJFU', name: 'Nagasaki', nameKo: '나가사키공항', lat: 32.9169, lng: 129.9135, type: 'international', intl: true, domestic: true, country: 'JP' }, + { id: 'KMI', icao: 'RJFM', name: 'Miyazaki', nameKo: '미야자키공항', lat: 31.8772, lng: 131.4493, type: 'domestic', intl: false, domestic: true, country: 'JP' }, + { id: 'OIT', icao: 'RJFO', name: 'Oita', nameKo: '오이타공항', lat: 33.4794, lng: 131.7372, type: 'domestic', intl: false, domestic: true, country: 'JP' }, + { id: 'NKM', icao: 'RJNA', name: 'Nagoya Komaki', nameKo: '나고야 코마키공항', lat: 35.2551, lng: 136.9240, type: 'domestic', intl: false, domestic: true, country: 'JP' }, + { id: 'AOJ', icao: 'RJSA', name: 'Aomori', nameKo: '아오모리공항', lat: 40.7347, lng: 140.6909, type: 'domestic', intl: false, domestic: true, country: 'JP' }, + { id: 'AKJ', icao: 'RJEC', name: 'Asahikawa', nameKo: '아사히카와공항', lat: 43.6708, lng: 142.4475, type: 'domestic', intl: false, domestic: true, country: 'JP' }, + + // ═══ 🇰🇵 북한 공항 ═══ + { id: 'FNJ', icao: 'ZKPY', name: 'Pyongyang Sunan Intl', nameKo: '평양순안국제공항', lat: 39.2241, lng: 125.6700, type: 'international', intl: true, domestic: true, country: 'KP' }, + { id: 'WOS', icao: 'ZKWS', name: 'Wonsan Kalma Intl', nameKo: '원산갈마국제공항', lat: 39.1668, lng: 127.4860, type: 'international', intl: true, domestic: false, country: 'KP' }, + { id: 'RGO', icao: 'ZKHM', name: 'Orang (Chongjin)', nameKo: '어랑공항 (청진)', lat: 41.4280, lng: 129.6470, type: 'domestic', intl: false, domestic: true, country: 'KP' }, + { id: 'DSO', icao: 'ZKSD', name: 'Samjiyon', nameKo: '삼지연공항', lat: 41.9070, lng: 128.4100, type: 'domestic', intl: false, domestic: true, country: 'KP' }, + { id: 'YJS', icao: 'ZKSE', name: 'Kaesong', nameKo: '개성공항', lat: 37.9700, lng: 126.5600, type: 'domestic', intl: false, domestic: true, country: 'KP' }, + + // ═══ 🇹🇼 대만 공항 ═══ + { id: 'TPE', icao: 'RCTP', name: 'Taiwan Taoyuan Intl', nameKo: '타오위안국제공항', lat: 25.0777, lng: 121.2325, type: 'international', intl: true, domestic: true, country: 'TW' }, + { id: 'TSA', icao: 'RCSS', name: 'Taipei Songshan', nameKo: '타이베이 쑹산공항', lat: 25.0694, lng: 121.5525, type: 'international', intl: true, domestic: true, country: 'TW' }, + { id: 'KHH', icao: 'RCKH', name: 'Kaohsiung Intl', nameKo: '가오슝국제공항', lat: 22.5771, lng: 120.3500, type: 'international', intl: true, domestic: true, country: 'TW' }, + { id: 'RMQ', icao: 'RCMQ', name: 'Taichung Intl', nameKo: '타이중국제공항', lat: 24.2646, lng: 120.6210, type: 'international', intl: true, domestic: true, country: 'TW' }, + { id: 'TNN', icao: 'RCNN', name: 'Tainan', nameKo: '타이난공항', lat: 22.9504, lng: 120.2057, type: 'domestic', intl: false, domestic: true, country: 'TW' }, + { id: 'HUN', icao: 'RCYU', name: 'Hualien', nameKo: '화롄공항', lat: 24.0236, lng: 121.6162, type: 'domestic', intl: false, domestic: true, country: 'TW' }, + { id: 'TTT', icao: 'RCFN', name: 'Taitung', nameKo: '타이둥공항', lat: 22.7550, lng: 121.1018, type: 'domestic', intl: false, domestic: true, country: 'TW' }, + { id: 'KNH', icao: 'RCBS', name: 'Kinmen', nameKo: '진먼공항 (금문도)', lat: 24.4279, lng: 118.3592, type: 'domestic', intl: false, domestic: true, country: 'TW' }, + { id: 'MZG', icao: 'RCQC', name: 'Magong (Penghu)', nameKo: '마공공항 (펑후)', lat: 23.5687, lng: 119.6282, type: 'domestic', intl: false, domestic: true, country: 'TW' }, ]; diff --git a/frontend/src/services/cctv.ts b/frontend/src/services/cctv.ts index 7bb3d1e..21acbed 100644 --- a/frontend/src/services/cctv.ts +++ b/frontend/src/services/cctv.ts @@ -13,9 +13,8 @@ export interface CctvCamera { source: 'KHOA'; } -/** KHOA HLS 스트림 */ -const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; -function khoa(site: string) { return `${KHOA_HLS}/${site}/s.m3u8`; } +/** KHOA HLS 스트림 — 백엔드 프록시 경유 */ +function khoa(site: string) { return `/api/kcg/cctv/hls/${site}/s.m3u8`; } export const KOREA_CCTV_CAMERAS: CctvCamera[] = [ // ═══ 서해 (West Sea) ═══ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0f574e2..ae5e892 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -89,6 +89,11 @@ export default defineConfig(({ mode }): UserConfig => ({ 'User-Agent': 'Mozilla/5.0 (compatible; KCG-Monitor/1.0)', }, }, + '/api/kcg/cctv': { + target: 'http://localhost:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/kcg/, '/api'), + }, '/api/kcg': { target: 'https://kcg.gc-si.dev', changeOrigin: true,