Merge pull request 'release: 중국어선 조업분석, 이란 시설, 레이어 재구성 + OSINT 중복 수정' (#86) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m24s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m24s
This commit is contained in:
커밋
d9d5a9483e
@ -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
|
||||
|
||||
@ -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<byte[]> 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<byte[]> 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);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,23 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 중국어선 조업분석: AIS Ship Type 30 + 선박명 패턴 분류, GC-KCG-2026-001/CSSA 기반 안강망 추가
|
||||
- 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단
|
||||
- 어구/어망 카테고리 신설 + 모선 연결선 시각화
|
||||
- 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본)
|
||||
- 이란 주변국 시설 레이어 (MEFacilityLayer 35개소)
|
||||
- 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영
|
||||
- 한국 해군부대 10개소, 항만, 풍력발전단지, 북한 발사대/미사일 이벤트 레이어
|
||||
- 정부기관 건물 레이어 (GovBuildingLayer)
|
||||
- CCTV 프록시 컨트롤러
|
||||
|
||||
### 변경
|
||||
- 레이어 재구성: 선박(최상위) → 항공망 → 해양안전 → 국가기관망
|
||||
- 오른쪽 패널 접기/펼치기 기능
|
||||
- 센서차트 기본 숨김
|
||||
- CCTV 레이어 리팩토링
|
||||
|
||||
## [2026-03-19.2]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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);
|
||||
@ -1236,8 +1252,8 @@
|
||||
}
|
||||
|
||||
.ship-tag-secondary {
|
||||
background: var(--kcg-border, rgba(100, 116, 139, 0.3));
|
||||
color: var(--kcg-text-secondary, #94a3b8);
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ship-tag-dim {
|
||||
@ -1281,7 +1297,7 @@
|
||||
}
|
||||
|
||||
.ship-popup-label {
|
||||
color: var(--kcg-muted, #64748b);
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
@ -1289,7 +1305,7 @@
|
||||
|
||||
.ship-popup-value {
|
||||
font-size: 10px;
|
||||
color: var(--kcg-text, #e2e8f0);
|
||||
color: #e2e8f0;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@ -1308,7 +1324,7 @@
|
||||
|
||||
.ship-popup-timestamp {
|
||||
font-size: 9px;
|
||||
color: var(--kcg-dim, #64748b);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.ship-popup-link {
|
||||
@ -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) */
|
||||
|
||||
@ -60,8 +60,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
ships: true,
|
||||
koreanShips: true,
|
||||
airports: true,
|
||||
sensorCharts: true,
|
||||
sensorCharts: false,
|
||||
oilFacilities: true,
|
||||
meFacilities: true,
|
||||
militaryOnly: false,
|
||||
});
|
||||
|
||||
@ -79,6 +80,13 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
osint: true,
|
||||
eez: true,
|
||||
piracy: true,
|
||||
windFarm: true,
|
||||
ports: true,
|
||||
militaryBases: true,
|
||||
govBuildings: true,
|
||||
nkLaunch: true,
|
||||
nkMissile: true,
|
||||
cnFishing: false,
|
||||
militaryOnly: false,
|
||||
});
|
||||
|
||||
@ -106,6 +114,26 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Nationality filter state (Korea tab)
|
||||
const [hiddenNationalities, setHiddenNationalities] = useState<Set<string>>(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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fishing vessel nationality filter state
|
||||
const [hiddenFishingNats, setHiddenFishingNats] = useState<Set<string>>(new Set());
|
||||
const toggleFishingNat = useCallback((nat: string) => {
|
||||
setHiddenFishingNats(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nat)) { next.delete(nat); } else { next.add(nat); }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [flyToTarget, setFlyToTarget] = useState<FlyToTarget | null>(null);
|
||||
|
||||
// 1시간마다 전체 데이터 강제 리프레시
|
||||
@ -154,6 +182,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
isLive,
|
||||
hiddenAcCategories,
|
||||
hiddenShipCategories,
|
||||
hiddenNationalities,
|
||||
refreshKey,
|
||||
});
|
||||
|
||||
@ -278,6 +307,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<span className="text-[11px]">🚢</span>
|
||||
{t('filters.ferryWatch')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${koreaLayers.cnFishing ? 'active live' : ''}`}
|
||||
onClick={() => toggleKoreaLayer('cnFishing')}
|
||||
title="중국어선감시"
|
||||
>
|
||||
<span className="text-[11px]">🎣</span>
|
||||
중국어선감시
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -413,6 +451,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
{ key: 'koreanShips', label: `\u{1F1F0}\u{1F1F7} ${t('layers.koreanShips')}`, color: '#00e5ff', count: iranData.koreanShips.length },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#f59e0b' },
|
||||
{ key: 'oilFacilities', label: t('layers.oilFacilities'), color: '#d97706' },
|
||||
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
|
||||
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
@ -532,20 +571,35 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
shipTotal={koreaData.ships.length}
|
||||
satelliteCount={koreaData.satPositions.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' },
|
||||
// 해양안전
|
||||
{ key: 'cables', label: t('layers.cables'), color: '#00e5ff', group: '해양안전' },
|
||||
{ key: 'cctv', label: t('layers.cctv'), color: '#ff6b6b', count: 15, group: '해양안전' },
|
||||
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 56, group: '해양안전' },
|
||||
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', group: '해양안전' },
|
||||
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', group: '해양안전' },
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' },
|
||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' },
|
||||
{ key: 'cnFishing', label: '🎣 중국어선 어구', color: '#f97316', group: '해양안전' },
|
||||
// 국가기관망
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '국가기관망' },
|
||||
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: 59, group: '국가기관망' },
|
||||
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8, group: '국가기관망' },
|
||||
{ key: 'ports', label: '항구', color: '#3b82f6', count: 46, group: '국가기관망' },
|
||||
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38, group: '국가기관망' },
|
||||
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32, group: '국가기관망' },
|
||||
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '국가기관망' },
|
||||
]}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onAcCategoryToggle={toggleAcCategory}
|
||||
onShipCategoryToggle={toggleShipCategory}
|
||||
shipsByNationality={koreaData.shipsByNationality}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
onNationalityToggle={toggleNationality}
|
||||
fishingByNationality={koreaData.fishingByNationality}
|
||||
hiddenFishingNats={hiddenFishingNats}
|
||||
onFishingNatToggle={toggleFishingNat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GeoEvent, Ship } from '../../types';
|
||||
import type { OsintItem } from '../../services/osint';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
|
||||
type DashboardTab = 'iran' | 'korea';
|
||||
|
||||
@ -346,6 +348,14 @@ function useTimeAgo() {
|
||||
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
|
||||
const { t } = useTranslation(['common', 'events', 'ships']);
|
||||
const timeAgo = useTimeAgo();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships']));
|
||||
const toggleCollapse = useCallback((key: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => events.filter(e => e.timestamp <= currentTime).reverse(),
|
||||
@ -459,11 +469,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
{/* OSINT Live Feed (live mode) */}
|
||||
{isLive && osintFeed.length > 0 && (
|
||||
<>
|
||||
<div className="osint-header">
|
||||
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('osint-live')}>
|
||||
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('osint-live') ? '▶' : '▼'}</span>
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{t('events:osint.liveTitle')}</span>
|
||||
<span className="osint-count">{osintFeed.length}</span>
|
||||
</div>
|
||||
{!collapsed.has('osint-live') && (
|
||||
<div className="osint-list">
|
||||
{osintFeed.map(item => {
|
||||
const catColor = OSINT_CAT_COLORS[item.category] || OSINT_CAT_COLORS.general;
|
||||
@ -490,6 +502,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLive && osintFeed.length === 0 && (
|
||||
@ -579,7 +592,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
|
||||
{/* 한국 선박 현황 — 선종별 분류 */}
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header">
|
||||
<div className="area-ship-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('kr-ships')}>
|
||||
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('kr-ships') ? '▶' : '▼'}</span>
|
||||
<span className="area-ship-icon">{'\u{1F1F0}\u{1F1F7}'}</span>
|
||||
<span className="area-ship-title">{t('ships:shipStatus.koreanTitle')}</span>
|
||||
<span className="area-ship-total">{koreanShips.length}{t('common:units.vessels')}</span>
|
||||
@ -594,7 +608,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{koreanShips.length > 0 && (() => {
|
||||
{!collapsed.has('kr-ships') && koreanShips.length > 0 && (() => {
|
||||
const groups: Record<string, Ship[]> = {};
|
||||
for (const s of koreanShips) {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
@ -613,19 +627,17 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
const moving = list.filter(s => s.speed > 0.5).length;
|
||||
const anchored = list.length - moving;
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||
background: `${mtColor}0a`,
|
||||
<div key={cat} className="font-mono" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px',
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -636,12 +648,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
|
||||
{/* 중국 선박 현황 */}
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header">
|
||||
<div className="area-ship-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('cn-ships')}>
|
||||
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('cn-ships') ? '▶' : '▼'}</span>
|
||||
<span className="area-ship-icon">{'\u{1F1E8}\u{1F1F3}'}</span>
|
||||
<span className="area-ship-title">{t('ships:shipStatus.chineseTitle')}</span>
|
||||
<span className="area-ship-total">{chineseShips.length}{t('common:units.vessels')}</span>
|
||||
</div>
|
||||
{chineseShips.length > 0 && (() => {
|
||||
{!collapsed.has('cn-ships') && chineseShips.length > 0 && (() => {
|
||||
const groups: Record<string, Ship[]> = {};
|
||||
for (const s of chineseShips) {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
@ -669,19 +682,17 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
const moving = list.filter(s => s.speed > 0.5).length;
|
||||
const anchored = list.length - moving;
|
||||
return (
|
||||
<div key={cat} className="flex items-center gap-1.5 px-2 py-1 rounded-r" style={{
|
||||
background: `${mtColor}0a`,
|
||||
<div key={cat} className="font-mono" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px',
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: mtColor }} />
|
||||
<span className="min-w-[70px] text-[11px] font-bold font-mono" style={{ color: mtColor }}>{mtLabel}</span>
|
||||
<span className="text-[13px] font-bold font-mono text-kcg-text">
|
||||
{list.length}<span className="text-[9px] font-normal text-kcg-muted">{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span className="ml-auto flex gap-1.5 text-[9px] font-mono">
|
||||
{moving > 0 && <span className="text-kcg-success">{t('ships:status.underway')} {moving}</span>}
|
||||
{anchored > 0 && <span className="text-kcg-danger">{t('ships:status.anchored')} {anchored}</span>}
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -690,10 +701,71 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 중국 어선 조업 분석 (GC-KCG-2026-001 기반) */}
|
||||
{chineseShips.length > 0 && (() => {
|
||||
const stats = aggregateFishingStats(chineseShips);
|
||||
if (stats.total === 0) return null;
|
||||
const gearOrder: FishingGearType[] = ['trawl_pair', 'trawl_single', 'gillnet', 'stow_net', 'purse_seine', 'carrier', 'unknown'];
|
||||
|
||||
return (
|
||||
<div className="iran-ship-summary">
|
||||
<div className="area-ship-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('cn-fishing')}>
|
||||
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('cn-fishing') ? '▶' : '▼'}</span>
|
||||
<span className="area-ship-icon">🎣</span>
|
||||
<span className="area-ship-title">중국어선 조업분석 ({stats.total}척)</span>
|
||||
<span className="area-ship-total" style={{ color: '#ef4444' }}>
|
||||
{stats.operating}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>척 조업중</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* 위험 경보 */}
|
||||
{!collapsed.has('cn-fishing') && (stats.critical > 0 || stats.high > 0) && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 mb-0.5 rounded border border-kcg-danger/30 bg-kcg-danger/10">
|
||||
<span className="text-sm">🚨</span>
|
||||
<span className="text-[10px] font-bold font-mono text-kcg-danger">
|
||||
{stats.critical > 0 && `CRITICAL ${stats.critical}척`}
|
||||
{stats.critical > 0 && stats.high > 0 && ' · '}
|
||||
{stats.high > 0 && `HIGH ${stats.high}척`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 조업/비조업 요약 + 어구별 분류 */}
|
||||
{!collapsed.has('cn-fishing') && (
|
||||
<>
|
||||
<div className="font-mono" style={{ display: 'flex', gap: 8, padding: '4px 8px', fontSize: 10 }}>
|
||||
<span style={{ color: '#ef4444' }}>🔴 조업 {stats.operating}</span>
|
||||
<span style={{ color: '#22c55e' }}>🟢 비조업 {stats.idle}</span>
|
||||
<span style={{ color: 'var(--kcg-muted)', marginLeft: 'auto' }}>총 {stats.total}척</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{gearOrder.map(gear => {
|
||||
const count = stats.byGear[gear];
|
||||
if (!count) return null;
|
||||
const info = GEAR_LABELS[gear];
|
||||
return (
|
||||
<div key={gear} className="font-mono" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px',
|
||||
borderLeft: `3px solid ${info.color}`,
|
||||
}}>
|
||||
<span style={{ fontSize: 10 }}>{info.icon}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, minWidth: 100, color: info.color }}>{info.ko}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{count}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>척</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
|
||||
{osintFeed.length > 0 && (
|
||||
<>
|
||||
<div className="osint-header">
|
||||
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('osint-korea')}>
|
||||
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('osint-korea') ? '▶' : '▼'}</span>
|
||||
<span className="osint-live-dot" />
|
||||
<span className="osint-title">{'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')}</span>
|
||||
<span className="osint-count">{(() => {
|
||||
@ -707,6 +779,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
}).length;
|
||||
})()}</span>
|
||||
</div>
|
||||
{!collapsed.has('osint-korea') && (
|
||||
<div className="osint-list">
|
||||
{(() => {
|
||||
const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil');
|
||||
@ -742,6 +815,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{osintFeed.length === 0 && (
|
||||
|
||||
@ -40,6 +40,7 @@ const MT_CAT_COLORS: Record<string, string> = {
|
||||
tanker: 'var(--kcg-ship-tanker)',
|
||||
passenger: 'var(--kcg-ship-passenger)',
|
||||
fishing: 'var(--kcg-ship-fishing)',
|
||||
fishing_gear: '#f97316',
|
||||
military: 'var(--kcg-ship-military)',
|
||||
tug_special: 'var(--kcg-ship-tug)',
|
||||
high_speed: 'var(--kcg-ship-highspeed)',
|
||||
@ -55,6 +56,7 @@ const SHIP_TYPE_LEGEND: [string, string][] = [
|
||||
['tanker', 'var(--kcg-ship-tanker)'],
|
||||
['passenger', 'var(--kcg-ship-passenger)'],
|
||||
['fishing', 'var(--kcg-ship-fishing)'],
|
||||
['fishing_gear', '#f97316'],
|
||||
['pleasure', 'var(--kcg-ship-pleasure)'],
|
||||
['military', 'var(--kcg-ship-military)'],
|
||||
['tug_special', 'var(--kcg-ship-tug)'],
|
||||
@ -63,15 +65,54 @@ 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;
|
||||
const MT_CATEGORIES = ['cargo', 'tanker', 'passenger', 'fishing', 'fishing_gear', '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;
|
||||
|
||||
// Fishing vessel nationality categories
|
||||
const FISHING_NAT_CATEGORIES = ['CN', 'KR', 'JP', 'other'] as const;
|
||||
const FISHING_NAT_LABELS: Record<string, string> = {
|
||||
CN: '🇨🇳 중국어선',
|
||||
KR: '🇰🇷 한국어선',
|
||||
JP: '🇯🇵 일본어선',
|
||||
other: '🏳️ 기타어선',
|
||||
};
|
||||
const FISHING_NAT_COLORS: Record<string, string> = {
|
||||
CN: '#ef4444',
|
||||
KR: '#3b82f6',
|
||||
JP: '#f472b6',
|
||||
other: '#6b7280',
|
||||
};
|
||||
const NAT_LABELS: Record<string, string> = {
|
||||
KR: '🇰🇷 한국',
|
||||
CN: '🇨🇳 중국',
|
||||
KP: '🇰🇵 북한',
|
||||
JP: '🇯🇵 일본',
|
||||
unclassified: '🏳️ 미분류',
|
||||
};
|
||||
const NAT_COLORS: Record<string, string> = {
|
||||
KR: '#3b82f6',
|
||||
CN: '#ef4444',
|
||||
KP: '#f97316',
|
||||
JP: '#f472b6',
|
||||
unclassified: '#6b7280',
|
||||
};
|
||||
|
||||
interface ExtraLayer {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
count?: number;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const GROUP_META: Record<string, { label: string; color: string }> = {
|
||||
'항공망': { label: '항공망', color: '#22d3ee' },
|
||||
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
|
||||
'해양안전': { label: '해양안전', color: '#3b82f6' },
|
||||
};
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
@ -85,6 +126,12 @@ interface LayerPanelProps {
|
||||
hiddenShipCategories: Set<string>;
|
||||
onAcCategoryToggle: (cat: string) => void;
|
||||
onShipCategoryToggle: (cat: string) => void;
|
||||
shipsByNationality?: Record<string, number>;
|
||||
hiddenNationalities?: Set<string>;
|
||||
onNationalityToggle?: (nat: string) => void;
|
||||
fishingByNationality?: Record<string, number>;
|
||||
hiddenFishingNats?: Set<string>;
|
||||
onFishingNatToggle?: (nat: string) => void;
|
||||
}
|
||||
|
||||
export function LayerPanel({
|
||||
@ -100,9 +147,15 @@ export function LayerPanel({
|
||||
hiddenShipCategories,
|
||||
onAcCategoryToggle,
|
||||
onShipCategoryToggle,
|
||||
shipsByNationality,
|
||||
hiddenNationalities,
|
||||
onNationalityToggle,
|
||||
fishingByNationality,
|
||||
hiddenFishingNats,
|
||||
onFishingNatToggle,
|
||||
}: LayerPanelProps) {
|
||||
const { t } = useTranslation(['common', 'ships']);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(['aircraft', 'ships']));
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(['ships']));
|
||||
const [legendOpen, setLegendOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = useCallback((key: string) => {
|
||||
@ -129,84 +182,7 @@ export function LayerPanel({
|
||||
<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"
|
||||
@ -223,6 +199,51 @@ export function LayerPanel({
|
||||
{MT_CATEGORIES.map(cat => {
|
||||
const count = shipsByMtCategory[cat] || 0;
|
||||
if (count === 0) return null;
|
||||
|
||||
// 어선은 국적별 하위 분류 표시
|
||||
if (cat === 'fishing' && fishingByNationality && hiddenFishingNats && onFishingNatToggle) {
|
||||
const isFishingExpanded = expanded.has('fishing-sub');
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ fontSize: 7, color: 'var(--kcg-dim)', width: 10, textAlign: 'center', cursor: 'pointer', flexShrink: 0 }}
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand('fishing-sub'); }}
|
||||
>
|
||||
{isFishingExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<CategoryToggle
|
||||
label={t(`ships:mtType.${cat}`, cat.toUpperCase())}
|
||||
color={MT_CAT_COLORS[cat] || 'var(--kcg-muted)'}
|
||||
count={count}
|
||||
hidden={hiddenShipCategories.has(cat)}
|
||||
onClick={() => onShipCategoryToggle(cat)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isFishingExpanded && !hiddenShipCategories.has('fishing') && (
|
||||
<div style={{ paddingLeft: 14 }}>
|
||||
{FISHING_NAT_CATEGORIES.map(nat => {
|
||||
const fCount = fishingByNationality[nat] || 0;
|
||||
if (fCount === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={`fishing-${nat}`}
|
||||
label={FISHING_NAT_LABELS[nat] || nat}
|
||||
color={FISHING_NAT_COLORS[nat] || '#888'}
|
||||
count={fCount}
|
||||
hidden={hiddenFishingNats.has(nat)}
|
||||
onClick={() => onFishingNatToggle(nat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={cat}
|
||||
@ -235,7 +256,9 @@ export function LayerPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ship type legend */}
|
||||
{/* Ship type legend (Korea tab only) */}
|
||||
{shipsByNationality && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="legend-toggle"
|
||||
@ -263,10 +286,119 @@ export function LayerPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Satellites (simple toggle) */}
|
||||
{/* Nationality tree (Korea tab only) */}
|
||||
{shipsByNationality && hiddenNationalities && onNationalityToggle && (
|
||||
<>
|
||||
<LayerTreeItem
|
||||
layerKey="nationality"
|
||||
label={`국적 분류 (${Object.values(shipsByNationality).reduce((a, b) => a + b, 0)})`}
|
||||
color="#8b5cf6"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('nationality')}
|
||||
onToggle={() => toggleExpand('nationality')}
|
||||
onExpand={() => toggleExpand('nationality')}
|
||||
/>
|
||||
{expanded.has('nationality') && (
|
||||
<div className="layer-tree-children">
|
||||
{NAT_CATEGORIES.map(nat => {
|
||||
const count = shipsByNationality[nat] || 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<CategoryToggle
|
||||
key={nat}
|
||||
label={NAT_LABELS[nat] || nat}
|
||||
color={NAT_COLORS[nat] || '#888'}
|
||||
count={count}
|
||||
hidden={hiddenNationalities.has(nat)}
|
||||
onClick={() => onNationalityToggle(nat)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══ 항공망 그룹 ═══ */}
|
||||
<LayerTreeItem
|
||||
layerKey="group-항공망"
|
||||
label="항공망"
|
||||
color="#22d3ee"
|
||||
active
|
||||
expandable
|
||||
isExpanded={expanded.has('group-항공망')}
|
||||
onToggle={() => toggleExpand('group-항공망')}
|
||||
onExpand={() => toggleExpand('group-항공망')}
|
||||
/>
|
||||
{expanded.has('group-항공망') && (
|
||||
<div className="layer-tree-children">
|
||||
{/* 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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
{/* Satellites */}
|
||||
<LayerTreeItem
|
||||
layerKey="satellites"
|
||||
label={`${t('layers.satellites')} (${satelliteCount})`}
|
||||
@ -274,9 +406,42 @@ export function LayerPanel({
|
||||
active={layers.satellites}
|
||||
onToggle={() => onToggle('satellites')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra layers (tab-specific) */}
|
||||
{extraLayers && extraLayers.map(el => (
|
||||
{/* Extra layers — grouped */}
|
||||
{extraLayers && (() => {
|
||||
const grouped: Record<string, ExtraLayer[]> = {};
|
||||
const ungrouped: ExtraLayer[] = [];
|
||||
for (const el of extraLayers) {
|
||||
if (el.group) {
|
||||
if (!grouped[el.group]) grouped[el.group] = [];
|
||||
grouped[el.group].push(el);
|
||||
} else {
|
||||
ungrouped.push(el);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Grouped layers */}
|
||||
{Object.entries(grouped).map(([groupName, items]) => {
|
||||
const meta = GROUP_META[groupName] || { label: groupName, color: '#888' };
|
||||
const isGroupExpanded = expanded.has(`group-${groupName}`);
|
||||
return (
|
||||
<div key={groupName}>
|
||||
<LayerTreeItem
|
||||
layerKey={`group-${groupName}`}
|
||||
label={meta.label}
|
||||
color={meta.color}
|
||||
active
|
||||
expandable
|
||||
isExpanded={isGroupExpanded}
|
||||
onToggle={() => toggleExpand(`group-${groupName}`)}
|
||||
onExpand={() => toggleExpand(`group-${groupName}`)}
|
||||
/>
|
||||
{isGroupExpanded && (
|
||||
<div className="layer-tree-children">
|
||||
{items.map(el => (
|
||||
<LayerTreeItem
|
||||
key={el.key}
|
||||
layerKey={el.key}
|
||||
@ -286,6 +451,25 @@ export function LayerPanel({
|
||||
onToggle={() => onToggle(el.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Ungrouped layers */}
|
||||
{ungrouped.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" />
|
||||
|
||||
|
||||
121
frontend/src/components/icons/FishingNetIcon.tsx
Normal file
121
frontend/src/components/icons/FishingNetIcon.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
// ═══ 어구/어망 아이콘 — 그물망 형태 SVG ═══
|
||||
|
||||
interface Props {
|
||||
color?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 기본 어망 아이콘 (반달형 그물 + 부표) */
|
||||
export function FishingNetIcon({ color = '#f97316', size = 16 }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 20" fill="none">
|
||||
{/* 그물망 곡선 (반달형) */}
|
||||
<path d="M2 4 Q6 18 12 18 Q18 18 22 4" stroke={color} strokeWidth="1.2" fill="none" />
|
||||
{/* 가로 그물선 */}
|
||||
<path d="M4 8 Q8 14 12 14 Q16 14 20 8" stroke={color} strokeWidth="0.6" opacity="0.7" />
|
||||
<path d="M6 12 Q9 16 12 16 Q15 16 18 12" stroke={color} strokeWidth="0.6" opacity="0.5" />
|
||||
{/* 세로 그물선 */}
|
||||
<line x1="7" y1="5.5" x2="8" y2="15" stroke={color} strokeWidth="0.5" opacity="0.6" />
|
||||
<line x1="12" y1="4" x2="12" y2="18" stroke={color} strokeWidth="0.5" opacity="0.6" />
|
||||
<line x1="17" y1="5.5" x2="16" y2="15" stroke={color} strokeWidth="0.5" opacity="0.6" />
|
||||
{/* 대각선 그물 */}
|
||||
<line x1="4" y1="6" x2="10" y2="16" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
<line x1="20" y1="6" x2="14" y2="16" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
<line x1="6" y1="10" x2="12" y2="18" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
<line x1="18" y1="10" x2="12" y2="18" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
{/* 부표 (상단 구슬) */}
|
||||
<circle cx="2" cy="4" r="1.5" fill={color} />
|
||||
<circle cx="7" cy="2" r="1.5" fill={color} />
|
||||
<circle cx="12" cy="1.5" r="1.5" fill={color} />
|
||||
<circle cx="17" cy="2" r="1.5" fill={color} />
|
||||
<circle cx="22" cy="4" r="1.5" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 트롤(저인망) 아이콘 — 자루형 그물 */
|
||||
export function TrawlNetIcon({ color = '#ef4444', size = 16 }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 20" fill="none">
|
||||
{/* 자루형 망 */}
|
||||
<path d="M1 2 L8 10 L8 18 L16 18 L16 10 L23 2" stroke={color} strokeWidth="1.2" fill="none" />
|
||||
{/* 그물 패턴 */}
|
||||
<line x1="4" y1="5" x2="10" y2="14" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="20" y1="5" x2="14" y2="14" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="8" y1="15" x2="16" y2="15" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
{/* 전개판 */}
|
||||
<rect x="0" y="1" width="3" height="2" rx="0.5" fill={color} opacity="0.8" />
|
||||
<rect x="21" y="1" width="3" height="2" rx="0.5" fill={color} opacity="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 자망(유자망) 아이콘 — 수직 그물벽 */
|
||||
export function GillnetIcon({ color = '#f97316', size = 16 }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 20" fill="none">
|
||||
{/* 수직 그물 */}
|
||||
<line x1="2" y1="3" x2="2" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
<line x1="6" y1="3" x2="6" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
<line x1="10" y1="3" x2="10" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
<line x1="14" y1="3" x2="14" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
<line x1="18" y1="3" x2="18" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
<line x1="22" y1="3" x2="22" y2="18" stroke={color} strokeWidth="0.8" />
|
||||
{/* 가로 연결 */}
|
||||
<line x1="2" y1="6" x2="22" y2="6" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
<line x1="2" y1="14" x2="22" y2="14" stroke={color} strokeWidth="0.5" opacity="0.5" />
|
||||
{/* 상단 부표 */}
|
||||
<circle cx="2" cy="2" r="1.3" fill={color} />
|
||||
<circle cx="8" cy="2" r="1.3" fill={color} />
|
||||
<circle cx="14" cy="2" r="1.3" fill={color} />
|
||||
<circle cx="20" cy="2" r="1.3" fill={color} />
|
||||
{/* 하단 침자 */}
|
||||
<rect x="1" y="18" width="2" height="1.5" fill={color} opacity="0.6" />
|
||||
<rect x="7" y="18" width="2" height="1.5" fill={color} opacity="0.6" />
|
||||
<rect x="13" y="18" width="2" height="1.5" fill={color} opacity="0.6" />
|
||||
<rect x="19" y="18" width="2" height="1.5" fill={color} opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 안강망(Stow net) 아이콘 — 조류 방향 자루형 */
|
||||
export function StowNetIcon({ color = '#eab308', size = 16 }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 20" fill="none">
|
||||
{/* 프레임 */}
|
||||
<rect x="2" y="2" width="8" height="16" rx="1" stroke={color} strokeWidth="1" fill="none" />
|
||||
{/* 자루 */}
|
||||
<path d="M10 4 L20 8 L20 12 L10 16" stroke={color} strokeWidth="1" fill="none" />
|
||||
{/* 그물 패턴 */}
|
||||
<line x1="4" y1="2" x2="4" y2="18" stroke={color} strokeWidth="0.4" opacity="0.5" />
|
||||
<line x1="7" y1="2" x2="7" y2="18" stroke={color} strokeWidth="0.4" opacity="0.5" />
|
||||
<line x1="2" y1="7" x2="10" y2="7" stroke={color} strokeWidth="0.4" opacity="0.5" />
|
||||
<line x1="2" y1="13" x2="10" y2="13" stroke={color} strokeWidth="0.4" opacity="0.5" />
|
||||
{/* 조류 화살표 */}
|
||||
<path d="M22 10 L18 8 M22 10 L18 12" stroke={color} strokeWidth="0.8" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 선망(위망) 아이콘 — 원형 포위 그물 */
|
||||
export function PurseSeineIcon({ color = '#3b82f6', size = 16 }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 20" fill="none">
|
||||
{/* 원형 그물 */}
|
||||
<ellipse cx="12" cy="10" rx="10" ry="8" stroke={color} strokeWidth="1" fill="none" />
|
||||
{/* 그물 패턴 */}
|
||||
<ellipse cx="12" cy="10" rx="6" ry="5" stroke={color} strokeWidth="0.5" opacity="0.4" />
|
||||
<line x1="12" y1="2" x2="12" y2="18" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
<line x1="2" y1="10" x2="22" y2="10" stroke={color} strokeWidth="0.4" opacity="0.4" />
|
||||
{/* 죔줄 */}
|
||||
<path d="M4 16 Q12 20 20 16" stroke={color} strokeWidth="0.8" strokeDasharray="2 1" opacity="0.6" />
|
||||
{/* 부표 */}
|
||||
<circle cx="2" cy="10" r="1.3" fill={color} />
|
||||
<circle cx="22" cy="10" r="1.3" fill={color} />
|
||||
<circle cx="12" cy="2" r="1.3" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/iran/MEFacilityLayer.tsx
Normal file
80
frontend/src/components/iran/MEFacilityLayer.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { ME_FACILITIES, ME_FACILITY_TYPE_META } from '../../data/middleEastFacilities';
|
||||
import type { MEFacility } from '../../data/middleEastFacilities';
|
||||
|
||||
export const MEFacilityLayer = memo(function MEFacilityLayer() {
|
||||
const [selected, setSelected] = useState<MEFacility | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ME_FACILITIES.map(f => {
|
||||
const meta = ME_FACILITY_TYPE_META[f.type];
|
||||
return (
|
||||
<Marker key={f.id} longitude={f.lng} latitude={f.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(f); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${meta.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${meta.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: meta.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const meta = ME_FACILITY_TYPE_META[selected.type];
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{selected.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.country}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°{selected.lat >= 0 ? 'N' : 'S'}, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@ -9,6 +9,7 @@ import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { MEFacilityLayer } from './MEFacilityLayer';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
|
||||
@ -433,6 +434,7 @@ export function ReplayMap({ events, currentTime, aircraft, satellites, ships, la
|
||||
{seismicMarker && <SeismicMarker {...seismicMarker} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.meFacilities && <MEFacilityLayer />}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import { DamagedShipLayer } from '../layers/DamagedShipLayer';
|
||||
import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { MEFacilityLayer } from './MEFacilityLayer';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
|
||||
@ -271,6 +272,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
{seismicMarker && <SeismicMarker {...seismicMarker} />}
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.meFacilities && <MEFacilityLayer />}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,9 +12,9 @@ const REGION_COLOR: Record<string, string> = {
|
||||
'동해': '#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,40 +55,49 @@ export function CctvLayer() {
|
||||
<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="font-mono" style={{ minWidth: 200 }}>
|
||||
{/* 헤더 - 팝업 가장자리까지 꽉 채움 */}
|
||||
<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' }}
|
||||
className="font-bold flex items-center gap-2 text-black"
|
||||
style={{
|
||||
background: REGION_COLOR[selected.region] || '#888',
|
||||
margin: '-10px -10px 0',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{/* 태그 */}
|
||||
<div className="flex gap-1.5 flex-wrap" style={{ marginTop: 16 }}>
|
||||
<span className="bg-kcg-success text-white font-bold" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||||
● {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' }}
|
||||
className="font-bold text-black"
|
||||
style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10, 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]">
|
||||
<span className="bg-kcg-border text-kcg-text-secondary" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||||
{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]">
|
||||
<span className="bg-kcg-card text-kcg-muted" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||||
{t('cctv.khoa')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] flex flex-col gap-0.5">
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
{/* 좌표 */}
|
||||
<div className="text-kcg-dim" style={{ fontSize: 10, marginTop: 10 }}>
|
||||
{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"
|
||||
className="w-full inline-flex items-center justify-center gap-1 bg-kcg-accent text-white border-none cursor-pointer font-mono"
|
||||
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 4, fontWeight: 700, marginTop: 10 }}
|
||||
>
|
||||
📺 {t('cctv.viewStream')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
@ -172,18 +181,26 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
|
||||
{/* Modal */}
|
||||
<div
|
||||
onClick={e => 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 */}
|
||||
<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">
|
||||
<div
|
||||
className="flex items-center justify-between bg-kcg-overlay border-b border-[#222]"
|
||||
style={{ padding: '8px 14px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-mono text-kcg-text" style={{ fontSize: 11 }}>
|
||||
<span
|
||||
className="text-white px-1.5 py-px rounded text-[9px] font-bold"
|
||||
className="text-white font-bold"
|
||||
style={{
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
fontSize: 9,
|
||||
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
|
||||
}}
|
||||
>
|
||||
@ -191,13 +208,14 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
|
||||
</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 }}
|
||||
className="font-bold text-black"
|
||||
style={{ padding: '1px 6px', borderRadius: 3, fontSize: 9, 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"
|
||||
className="bg-kcg-border border-none text-white rounded cursor-pointer font-bold flex items-center justify-center"
|
||||
style={{ width: 24, height: 24, fontSize: 14 }}
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
@ -212,7 +230,7 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
|
||||
{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 className="text-kcg-muted font-mono" style={{ fontSize: 11 }}>{t('cctv.connectingEllipsis')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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')}</a>
|
||||
</div>
|
||||
)}
|
||||
@ -232,14 +251,14 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
|
||||
{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">
|
||||
<span className="font-bold font-mono px-2 py-0.5 rounded bg-black/70 text-white" style={{ fontSize: 10 }}>
|
||||
{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]">
|
||||
<span className="font-bold px-1.5 py-0.5 rounded bg-[rgba(239,68,68,0.3)] text-[#f87171]" style={{ fontSize: 9 }}>
|
||||
● {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">
|
||||
<div className="absolute bottom-2.5 left-2.5 font-mono px-2 py-0.5 rounded bg-black/70 text-kcg-muted" style={{ fontSize: 9 }}>
|
||||
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
|
||||
</div>
|
||||
</>
|
||||
@ -247,7 +266,10 @@ function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => voi
|
||||
</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">
|
||||
<div
|
||||
className="flex items-center justify-between bg-kcg-overlay border-t border-[#222] font-mono text-kcg-dim"
|
||||
style={{ padding: '6px 14px', fontSize: 9 }}
|
||||
>
|
||||
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
|
||||
<span>{t('cctv.khoaFull')}</span>
|
||||
</div>
|
||||
|
||||
267
frontend/src/components/korea/ChineseFishingOverlay.tsx
Normal file
267
frontend/src/components/korea/ChineseFishingOverlay.tsx
Normal file
@ -0,0 +1,267 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { Ship } from '../../types';
|
||||
import { analyzeFishing, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { FishingNetIcon, TrawlNetIcon, GillnetIcon, StowNetIcon, PurseSeineIcon } from '../icons/FishingNetIcon';
|
||||
|
||||
/** 어구 아이콘 컴포넌트 매핑 */
|
||||
function GearIcon({ gear, size = 14 }: { gear: FishingGearType; size?: number }) {
|
||||
const meta = GEAR_LABELS[gear];
|
||||
const color = meta?.color || '#888';
|
||||
switch (gear) {
|
||||
case 'trawl_pair':
|
||||
case 'trawl_single':
|
||||
return <TrawlNetIcon color={color} size={size} />;
|
||||
case 'gillnet':
|
||||
return <GillnetIcon color={color} size={size} />;
|
||||
case 'stow_net':
|
||||
return <StowNetIcon color={color} size={size} />;
|
||||
case 'purse_seine':
|
||||
return <PurseSeineIcon color={color} size={size} />;
|
||||
default:
|
||||
return <FishingNetIcon color={color} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** 선박 역할 추정 — 속도/크기/카테고리 기반 */
|
||||
function estimateRole(ship: Ship): { role: string; roleKo: string; color: string } {
|
||||
const mtCat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const speed = ship.speed;
|
||||
const len = ship.length || 0;
|
||||
|
||||
// 운반선: 화물선/대형/미분류 + 저속
|
||||
if (mtCat === 'cargo' || (mtCat === 'unspecified' && len > 50)) {
|
||||
return { role: 'FC', roleKo: '운반', color: '#f97316' };
|
||||
}
|
||||
|
||||
// 어선 분류
|
||||
if (mtCat === 'fishing' || ship.category === 'fishing') {
|
||||
// 대형(>200톤급, 길이 40m+) → 본선
|
||||
if (len >= 40) {
|
||||
return { role: 'PT', roleKo: '본선', color: '#ef4444' };
|
||||
}
|
||||
// 소형(<30m) + 트롤 속도 → 부속선
|
||||
if (len > 0 && len < 30 && speed >= 2 && speed <= 5) {
|
||||
return { role: 'PT-S', roleKo: '부속', color: '#fb923c' };
|
||||
}
|
||||
// 기본 어선
|
||||
return { role: 'FV', roleKo: '어선', color: '#22c55e' };
|
||||
}
|
||||
|
||||
return { role: '', roleKo: '', color: '#6b7280' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 어구/어망 이름에서 모선명 추출
|
||||
* 패턴: "중국명칭숫자5자리_숫자_숫자" → 앞부분이 모선명
|
||||
* 예: "鲁荣渔12345_1_2" → "鲁荣渔12345"
|
||||
* "浙象渔05678_3_1" → "浙象渔05678"
|
||||
* 또는 "이름%" → "이름" 부분이 모선명
|
||||
*/
|
||||
function extractParentName(gearName: string): string | null {
|
||||
// 패턴1: 이름_숫자_숫자 또는 이름_숫자_
|
||||
const m1 = gearName.match(/^(.+?)_\d+_\d*$/);
|
||||
if (m1) return m1[1].trim();
|
||||
const m2 = gearName.match(/^(.+?)_\d+_$/);
|
||||
if (m2) return m2[1].trim();
|
||||
// 패턴2: 이름%
|
||||
if (gearName.endsWith('%')) return gearName.slice(0, -1).trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
interface GearToParentLink {
|
||||
gear: Ship;
|
||||
parent: Ship;
|
||||
parentName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
}
|
||||
|
||||
export function ChineseFishingOverlay({ ships }: Props) {
|
||||
// 중국 어선만 필터링
|
||||
const chineseFishing = useMemo(() => {
|
||||
return ships.filter(s => {
|
||||
if (s.flag !== 'CN') return false;
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
return cat === 'fishing' || s.category === 'fishing';
|
||||
});
|
||||
}, [ships]);
|
||||
|
||||
// 조업 분석 결과
|
||||
const analyzed = useMemo(() => {
|
||||
return chineseFishing.map(s => ({
|
||||
ship: s,
|
||||
analysis: analyzeFishing(s),
|
||||
role: estimateRole(s),
|
||||
}));
|
||||
}, [chineseFishing]);
|
||||
|
||||
// 조업 중인 선박만 (어구 아이콘 표시용)
|
||||
const operating = useMemo(() => analyzed.filter(a => a.analysis.isOperating), [analyzed]);
|
||||
|
||||
// 어구/어망 → 모선 연결 탐지
|
||||
const gearLinks: GearToParentLink[] = useMemo(() => {
|
||||
// 어구/어망 선박 (이름_숫자_ 또는 이름% 패턴)
|
||||
const gearPattern = /^.+_\d+_\d*$|%$/;
|
||||
const gearShips = ships.filter(s => gearPattern.test(s.name));
|
||||
|
||||
if (gearShips.length === 0) return [];
|
||||
|
||||
// 모선 후보 (모든 선박의 이름 → Ship 매핑)
|
||||
const nameMap = new Map<string, Ship>();
|
||||
for (const s of ships) {
|
||||
if (!gearPattern.test(s.name) && s.name) {
|
||||
// 정확한 이름 매핑
|
||||
nameMap.set(s.name.trim(), s);
|
||||
}
|
||||
}
|
||||
|
||||
const links: GearToParentLink[] = [];
|
||||
for (const gear of gearShips) {
|
||||
const parentName = extractParentName(gear.name);
|
||||
if (!parentName) continue;
|
||||
|
||||
// 정확히 일치하는 모선 찾기
|
||||
let parent = nameMap.get(parentName);
|
||||
|
||||
// 정확 매칭 없으면 부분 매칭 (앞부분이 같은 선박)
|
||||
if (!parent) {
|
||||
for (const [name, ship] of nameMap) {
|
||||
if (name.startsWith(parentName) || parentName.startsWith(name)) {
|
||||
parent = ship;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
links.push({ gear, parent, parentName });
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}, [ships]);
|
||||
|
||||
// 어구-모선 연결선 GeoJSON
|
||||
const gearLineGeoJson = useMemo(() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: gearLinks.map(link => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { gearMmsi: link.gear.mmsi, parentMmsi: link.parent.mmsi },
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [
|
||||
[link.gear.lng, link.gear.lat],
|
||||
[link.parent.lng, link.parent.lat],
|
||||
],
|
||||
},
|
||||
})),
|
||||
}), [gearLinks]);
|
||||
|
||||
// 운반선 추정 (중국 화물선 중 어선 근처)
|
||||
const carriers = useMemo(() => {
|
||||
return ships.filter(s => {
|
||||
if (s.flag !== 'CN') return false;
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
if (cat !== 'cargo' && cat !== 'unspecified') return false;
|
||||
// 어선 5NM 이내에 있는 화물선
|
||||
return chineseFishing.some(f => {
|
||||
const dlat = Math.abs(s.lat - f.lat);
|
||||
const dlng = Math.abs(s.lng - f.lng);
|
||||
return dlat < 0.08 && dlng < 0.08; // ~5NM 근사
|
||||
});
|
||||
}).slice(0, 50); // 최대 50척
|
||||
}, [ships, chineseFishing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 어구/어망 → 모선 연결선 */}
|
||||
{gearLineGeoJson.features.length > 0 && (
|
||||
<Source id="gear-parent-lines" type="geojson" data={gearLineGeoJson}>
|
||||
<Layer
|
||||
id="gear-parent-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#f97316',
|
||||
'line-width': 1.5,
|
||||
'line-dasharray': [2, 2],
|
||||
'line-opacity': 0.6,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* 어구/어망 위치 마커 (모선 연결된 것) */}
|
||||
{gearLinks.map(link => (
|
||||
<Marker key={`gearlink-${link.gear.mmsi}`} longitude={link.gear.lng} latitude={link.gear.lat} anchor="center">
|
||||
<div style={{ filter: 'drop-shadow(0 0 3px #f9731688)', pointerEvents: 'none' }}>
|
||||
<FishingNetIcon color="#f97316" size={10} />
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: '#f97316', textAlign: 'center',
|
||||
textShadow: '0 0 2px #000', fontWeight: 700, marginTop: -1,
|
||||
whiteSpace: 'nowrap', pointerEvents: 'none',
|
||||
}}>
|
||||
← {link.parentName}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 조업 중 어선 — 어구 아이콘 */}
|
||||
{operating.map(({ ship, analysis }) => {
|
||||
const meta = GEAR_LABELS[analysis.gearType];
|
||||
return (
|
||||
<Marker key={`gear-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="bottom">
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
filter: `drop-shadow(0 0 3px ${meta?.color || '#f97316'}88)`,
|
||||
opacity: 0.85,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<GearIcon gear={analysis.gearType} size={12} />
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 본선/부속선/어선 역할 라벨 */}
|
||||
{analyzed.filter(a => a.role.role).map(({ ship, role }) => (
|
||||
<Marker key={`role-${ship.mmsi}`} longitude={ship.lng} latitude={ship.lat} anchor="top">
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: role.color,
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{role.roleKo}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 운반선 라벨 */}
|
||||
{carriers.map(s => (
|
||||
<Marker key={`carrier-${s.mmsi}`} longitude={s.lng} latitude={s.lat} anchor="top">
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
fontSize: 5,
|
||||
fontWeight: 700,
|
||||
color: '#f97316',
|
||||
textShadow: '0 0 2px #000, 0 0 2px #000',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
운반
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ const TYPE_COLOR: Record<CoastGuardType, string> = {
|
||||
station: '#4dabf7',
|
||||
substation: '#69db7c',
|
||||
vts: '#da77f2',
|
||||
navy: '#3b82f6',
|
||||
};
|
||||
|
||||
const TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
@ -18,6 +19,7 @@ const TYPE_SIZE: Record<CoastGuardType, number> = {
|
||||
station: 16,
|
||||
substation: 13,
|
||||
vts: 14,
|
||||
navy: 18,
|
||||
};
|
||||
|
||||
/** 해경 로고 SVG — 작은 방패+앵커 심볼 */
|
||||
@ -25,6 +27,17 @@ function CoastGuardIcon({ type, size }: { type: CoastGuardType; size: number })
|
||||
const color = TYPE_COLOR[type];
|
||||
const isVts = type === 'vts';
|
||||
|
||||
if (type === 'navy') {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M2 16 Q12 20 22 16 L22 12 Q12 8 2 12 Z" fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="12" y1="4" x2="12" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="4" r="2" fill={color} />
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke={color} strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVts) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
@ -76,6 +89,14 @@ export function CoastGuardLayer() {
|
||||
{f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'navy' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
textShadow: '0 0 3px #3b82f6, 0 0 2px #000',
|
||||
}} className="whitespace-nowrap font-bold tracking-wider text-[#3b82f6]">
|
||||
{f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)}
|
||||
</div>
|
||||
)}
|
||||
{f.type === 'vts' && (
|
||||
<div style={{
|
||||
fontSize: 5,
|
||||
@ -93,23 +114,37 @@ export function CoastGuardLayer() {
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="min-w-[200px] font-mono text-xs">
|
||||
<div style={{
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||
{selected.name}
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
gap: 6, padding: '6px 10px',
|
||||
}}>
|
||||
{selected.type === 'navy' ? (
|
||||
<span style={{ fontSize: 16 }}>⚓</span>
|
||||
) : selected.type === 'vts' ? (
|
||||
<span style={{ fontSize: 16 }}>📡</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 16 }}>🚔</span>
|
||||
)}
|
||||
<strong style={{ fontSize: 13 }}>{selected.name}</strong>
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: TYPE_COLOR[selected.type],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-black">
|
||||
color: selected.type === 'vts' ? '#fff' : '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{CG_TYPE_LABEL[selected.type]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] font-bold text-[#4dabf7]">
|
||||
<span style={{
|
||||
background: '#333', color: '#4dabf7',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('coastGuard.agency')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
|
||||
96
frontend/src/components/korea/GovBuildingLayer.tsx
Normal file
96
frontend/src/components/korea/GovBuildingLayer.tsx
Normal file
@ -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<string, { color: string; flag: string; label: string }> = {
|
||||
CN: { color: '#ef4444', flag: '🇨🇳', label: '중국' },
|
||||
JP: { color: '#f472b6', flag: '🇯🇵', label: '일본' },
|
||||
};
|
||||
|
||||
const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }> = {
|
||||
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<GovBuilding | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{GOV_BUILDINGS.map(g => {
|
||||
const ts = TYPE_STYLE[g.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Marker key={g.id} longitude={g.lng} latitude={g.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(g); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{g.nameKo.length > 10 ? g.nameKo.slice(0, 10) + '..' : g.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.executive;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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<string, { intl: string; domestic: string; flag: string; label: string }> = {
|
||||
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<KoreanAirport | null>(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 (
|
||||
<Marker key={ap.id} longitude={ap.lng} latitude={ap.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ap); }}>
|
||||
@ -37,37 +53,59 @@ export function KoreaAirportLayer() {
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
{selected && (() => {
|
||||
const color = getColor(selected);
|
||||
const info = getCountryInfo(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="260px" className="gl-popup">
|
||||
<div className="min-w-[180px] font-mono text-xs">
|
||||
<div style={{
|
||||
background: selected.intl ? '#a78bfa' : '#7c8aaa',
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-[13px] font-bold text-black">
|
||||
{selected.nameKo}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{info.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
{selected.intl && (
|
||||
<span className="rounded-sm bg-[#a78bfa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||
<span style={{
|
||||
background: color, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.international')}
|
||||
</span>
|
||||
)}
|
||||
{selected.domestic && (
|
||||
<span className="rounded-sm bg-[#7c8aaa] px-1.5 py-px text-[10px] font-bold text-black">
|
||||
<span style={{
|
||||
background: '#555', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{t('airport.domestic')}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
{selected.id} / {selected.icao}
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-kcg-dim">
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">IATA : </span><strong>{selected.id}</strong></div>
|
||||
<div><span className="popup-label">ICAO : </span><strong>{selected.icao}</strong></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.flightradar24.com/airport/${selected.id.toLowerCase()}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
Flightradar24 →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,13 @@ 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 { ChineseFishingOverlay } from './ChineseFishingOverlay';
|
||||
import { fetchKoreaInfra } from '../../services/infra';
|
||||
import type { PowerFacility } from '../../services/infra';
|
||||
import type { Ship, Aircraft, SatellitePosition } from '../../types';
|
||||
@ -255,6 +262,13 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre
|
||||
{layers.aircraft && aircraft.length > 0 && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.cables && <SubmarineCableLayer />}
|
||||
{layers.cctv && <CctvLayer />}
|
||||
{layers.windFarm && <WindFarmLayer />}
|
||||
{layers.ports && <PortLayer />}
|
||||
{layers.militaryBases && <MilitaryBaseLayer />}
|
||||
{layers.govBuildings && <GovBuildingLayer />}
|
||||
{layers.nkLaunch && <NKLaunchLayer />}
|
||||
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
|
||||
{layers.cnFishing && <ChineseFishingOverlay ships={ships} />}
|
||||
{layers.airports && <KoreaAirportLayer />}
|
||||
{layers.coastGuard && <CoastGuardLayer />}
|
||||
{layers.navWarning && <NavWarningLayer />}
|
||||
|
||||
108
frontend/src/components/korea/MilitaryBaseLayer.tsx
Normal file
108
frontend/src/components/korea/MilitaryBaseLayer.tsx
Normal file
@ -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<string, { color: string; flag: string; label: string }> = {
|
||||
CN: { color: '#ef4444', flag: '🇨🇳', label: '중국' },
|
||||
JP: { color: '#f472b6', flag: '🇯🇵', label: '일본' },
|
||||
KP: { color: '#f97316', flag: '🇰🇵', label: '북한' },
|
||||
TW: { color: '#10b981', flag: '🇹🇼', label: '대만' },
|
||||
};
|
||||
|
||||
const TYPE_STYLE: Record<string, { icon: string; label: string; color: string }> = {
|
||||
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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,8 22,16 12,22 2,16 2,8" fill="rgba(0,0,0,0.6)" stroke={ts.color} strokeWidth="1.5" />
|
||||
<text x="12" y="14" textAnchor="middle" fontSize="9" fill={ts.color}>{ts.icon}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MilitaryBaseLayer() {
|
||||
const [selected, setSelected] = useState<MilitaryBase | null>(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 (
|
||||
<Marker key={base.id} longitude={base.lng} latitude={base.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(base); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${ts.color}88)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: 3,
|
||||
background: 'rgba(0,0,0,0.7)', border: `1.5px solid ${ts.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
}}>
|
||||
{ts.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: ts.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{base.nameKo.length > 12 ? base.nameKo.slice(0, 12) + '..' : base.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const cs = COUNTRY_STYLE[selected.country] || COUNTRY_STYLE.CN;
|
||||
const ts = TYPE_STYLE[selected.type] || TYPE_STYLE.army;
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="300px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 220 }}>
|
||||
<div className="popup-header" style={{ background: ts.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{cs.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>{ts.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: ts.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{ts.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: cs.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{cs.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-text)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">시설명 : </span><strong>{selected.name}</strong></div>
|
||||
<div><span className="popup-label">유형 : </span>{ts.label}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/korea/NKLaunchLayer.tsx
Normal file
89
frontend/src/components/korea/NKLaunchLayer.tsx
Normal file
@ -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<NKLaunchSite | null>(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 (
|
||||
<Marker key={site.id} longitude={site.lng} latitude={site.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(site); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${meta.color}aa)` }}
|
||||
>
|
||||
<div style={{
|
||||
width: size, height: size,
|
||||
borderRadius: isArtillery ? '50%' : 4,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
border: `2px solid ${meta.color}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: isArtillery ? 8 : 10,
|
||||
}}>
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 5, color: meta.color, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{site.nameKo.length > 10 ? site.nameKo.slice(0, 10) + '..' : site.nameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const meta = NK_LAUNCH_TYPE_META[selected.type];
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 240 }}>
|
||||
<div className="popup-header" style={{ background: meta.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>{meta.icon} {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: meta.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#f97316', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
북한
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
{selected.recentUse && (
|
||||
<div style={{ fontSize: 10, color: '#f87171', marginBottom: 4 }}>
|
||||
최근: {selected.recentUse}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--kcg-muted)', marginBottom: 4 }}>
|
||||
{selected.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/korea/NKMissileEventLayer.tsx
Normal file
181
frontend/src/components/korea/NKMissileEventLayer.tsx
Normal file
@ -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<NKMissileEvent | null>(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 (
|
||||
<>
|
||||
{/* 궤적 라인 */}
|
||||
<Source id="nk-missile-lines" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="nk-missile-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#ef4444',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [4, 3],
|
||||
'line-opacity': 0.7,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 발사 지점 (▲) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`launch-${ev.id}`} longitude={ev.launchLng} latitude={ev.launchLat} anchor="center">
|
||||
<div style={{ filter: `drop-shadow(0 0 4px ${color}aa)`, opacity: today ? 1 : 0.35 }}>
|
||||
<svg width={12} height={12} viewBox="0 0 24 24" fill="none">
|
||||
<polygon points="12,2 22,20 2,20" fill={color} stroke="#fff" strokeWidth="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 (✕ + 정보 라벨) */}
|
||||
{NK_MISSILE_EVENTS.map(ev => {
|
||||
const color = getMissileColor(ev.type);
|
||||
const today = isToday(ev.date);
|
||||
return (
|
||||
<Marker key={`impact-${ev.id}`} longitude={ev.impactLng} latitude={ev.impactLat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(ev); }}>
|
||||
<div className="cursor-pointer flex flex-col items-center" style={{
|
||||
filter: `drop-shadow(0 0 ${today ? '6px' : '3px'} ${color})`,
|
||||
opacity: today ? 1 : 0.4,
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
<svg width={16} height={16} 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.5" />
|
||||
<line x1="7" y1="7" x2="17" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="17" y1="7" x2="7" y2="17" stroke={color} strokeWidth="2.5" strokeLinecap="round" />
|
||||
{today && (
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke={color} strokeWidth="1" opacity="0.4">
|
||||
<animate attributeName="r" values="10;18;10" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.4;0;0.4" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
</svg>
|
||||
<div style={{
|
||||
fontSize: 5, color, fontWeight: 700, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{ev.date.slice(5)} {ev.time} ← {ev.launchNameKo}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 낙하 지점 팝업 */}
|
||||
{selected && (() => {
|
||||
const color = getMissileColor(selected.type);
|
||||
return (
|
||||
<Popup longitude={selected.impactLng} latitude={selected.impactLat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="340px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 260 }}>
|
||||
<div className="popup-header" style={{ background: color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🇰🇵</span>
|
||||
<strong style={{ fontSize: 13 }}>🚀 {selected.typeKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ background: color, color: '#fff', padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700 }}>
|
||||
{selected.type}
|
||||
</span>
|
||||
<span style={{ background: '#333', color: '#ccc', padding: '2px 8px', borderRadius: 3, fontSize: 10 }}>
|
||||
{selected.date} {selected.time} KST
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '3px 12px', marginBottom: 6 }}>
|
||||
<div><span className="popup-label">발사지 : </span><strong>{selected.launchNameKo}</strong></div>
|
||||
<div><span className="popup-label">발사시각 : </span><strong>{selected.time} KST</strong></div>
|
||||
<div><span className="popup-label">비행거리 : </span><strong>{selected.distanceKm.toLocaleString()} km</strong></div>
|
||||
<div><span className="popup-label">최고고도 : </span><strong>{selected.altitudeKm.toLocaleString()} km</strong></div>
|
||||
<div><span className="popup-label">비행시간 : </span><strong>{selected.flightMin}분</strong></div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.note}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#999', marginBottom: 4 }}>
|
||||
낙하지점: {selected.impactLat.toFixed(2)}°N, {selected.impactLng.toFixed(2)}°E
|
||||
</div>
|
||||
|
||||
{/* 인근 선박 */}
|
||||
<div style={{ borderTop: '1px solid #333', paddingTop: 6, marginTop: 4 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: nearbyShips.length > 0 ? '#f87171' : '#22c55e', marginBottom: 4 }}>
|
||||
{nearbyShips.length > 0
|
||||
? `⚠️ 낙하지점 50km 내 선박 ${nearbyShips.length}척`
|
||||
: '✅ 낙하지점 50km 내 선박 없음'}
|
||||
</div>
|
||||
{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 (
|
||||
<div key={s.mmsi} style={{ fontSize: 9, color: '#aaa', display: 'flex', gap: 4, padding: '1px 0' }}>
|
||||
<span style={{ color: '#f87171' }}>●</span>
|
||||
<span>{s.name || s.mmsi}</span>
|
||||
<span style={{ color: '#888' }}>{cat}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#f97316' }}>{d.toFixed(1)}km</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{nearbyShips.length > 5 && (
|
||||
<div style={{ fontSize: 9, color: '#666' }}>...외 {nearbyShips.length - 5}척</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -73,33 +73,45 @@ export function NavWarningLayer() {
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="320px" className="gl-popup">
|
||||
<div className="min-w-[240px] font-mono text-xs">
|
||||
<div className="font-mono" style={{ minWidth: 240, fontSize: 12 }}>
|
||||
<div style={{
|
||||
background: ORG_COLOR[selected.org],
|
||||
}} className="-mx-2.5 -mt-2.5 mb-2 flex items-center gap-1.5 rounded-t px-2 py-1 text-xs font-bold text-white">
|
||||
color: '#fff',
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
margin: '-10px -10px 0',
|
||||
borderRadius: '5px 5px 0 0',
|
||||
}}>
|
||||
{selected.title}
|
||||
</div>
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 10, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: LEVEL_COLOR[selected.level],
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold text-white">
|
||||
color: '#fff',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_LEVEL_LABEL[selected.level]}
|
||||
</span>
|
||||
<span style={{
|
||||
background: ORG_COLOR[selected.org] + '33',
|
||||
color: ORG_COLOR[selected.org],
|
||||
border: `1px solid ${ORG_COLOR[selected.org]}44`,
|
||||
}} className="rounded-sm px-1.5 py-px text-[10px] font-bold">
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3, fontWeight: 700,
|
||||
}}>
|
||||
{NW_ORG_LABEL[selected.org]}
|
||||
</span>
|
||||
<span className="rounded-sm bg-kcg-card px-1.5 py-px text-[10px] text-kcg-muted">
|
||||
<span style={{
|
||||
background: 'var(--kcg-card)', color: 'var(--kcg-muted)',
|
||||
padding: '1px 6px', fontSize: 10, borderRadius: 3,
|
||||
}}>
|
||||
{selected.area}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-1.5 text-[11px] leading-snug text-kcg-text-secondary">
|
||||
<div style={{ fontSize: 11, color: '#ccc', lineHeight: 1.4, marginBottom: 6 }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-[9px] text-kcg-dim">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 9, color: '#666' }}>
|
||||
<div>{t('navWarning.altitude')}: {selected.altitude}</div>
|
||||
<div>{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E</div>
|
||||
<div>{t('navWarning.source')}: {selected.source}</div>
|
||||
@ -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')}</a>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
101
frontend/src/components/korea/PortLayer.tsx
Normal file
101
frontend/src/components/korea/PortLayer.tsx
Normal file
@ -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<string, { color: string; flag: string; label: string }> = {
|
||||
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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="5" r="2.5" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
<line x1="12" y1="7.5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="7" y1="12" x2="17" y2="12" stroke={color} strokeWidth="1.5" />
|
||||
<path d="M5 18 Q8 21 12 21 Q16 21 19 18" stroke={color} strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortLayer() {
|
||||
const [selected, setSelected] = useState<Port | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{EAST_ASIA_PORTS.map(p => {
|
||||
const s = getStyle(p);
|
||||
const size = p.type === 'major' ? 16 : 12;
|
||||
return (
|
||||
<Marker key={p.id} longitude={p.lng} latitude={p.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(p); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 2px ${s.color}88)` }}
|
||||
>
|
||||
<AnchorIcon color={s.color} size={size} />
|
||||
<div style={{
|
||||
fontSize: 5, color: s.color, marginTop: 0,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{p.nameKo.replace('항', '')}
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (() => {
|
||||
const s = getStyle(selected);
|
||||
return (
|
||||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||||
onClose={() => setSelected(null)} closeOnClick={false}
|
||||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||||
<div className="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: s.color, color: '#fff', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>{s.flag}</span>
|
||||
<strong style={{ fontSize: 13 }}>⚓ {selected.nameKo}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: s.color, color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.type === 'major' ? '주요항만' : '항만'}
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">항구 : </span><strong>{selected.nameKo}</strong></div>
|
||||
<div><span className="popup-label">영문 : </span>{selected.name}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
||||
<a href={`https://www.marinetraffic.com/en/ais/details/ports/${selected.id}`}
|
||||
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
||||
MarineTraffic →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/korea/WindFarmLayer.tsx
Normal file
95
frontend/src/components/korea/WindFarmLayer.tsx
Normal file
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="10" x2="11" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
<line x1="12" y1="10" x2="13" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="9" r="1.8" fill={COLOR} />
|
||||
<path d="M12 9 L11.5 1 Q12 0 12.5 1 Z" fill={COLOR} opacity="0.9" />
|
||||
<path d="M12 9 L18 14 Q18.5 13 17.5 12.5 Z" fill={COLOR} opacity="0.9" />
|
||||
<path d="M12 9 L6 14 Q5.5 13 6.5 12.5 Z" fill={COLOR} opacity="0.9" />
|
||||
<line x1="8" y1="23" x2="16" y2="23" stroke={COLOR} strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'운영중': '#22c55e',
|
||||
'건설중': '#eab308',
|
||||
'계획': '#64748b',
|
||||
};
|
||||
|
||||
export function WindFarmLayer() {
|
||||
const [selected, setSelected] = useState<WindFarm | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{KOREA_WIND_FARMS.map(wf => (
|
||||
<Marker key={wf.id} longitude={wf.lng} latitude={wf.lat} anchor="center"
|
||||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(wf); }}>
|
||||
<div
|
||||
className="flex flex-col items-center cursor-pointer"
|
||||
style={{ filter: `drop-shadow(0 0 3px ${COLOR}88)` }}
|
||||
>
|
||||
<WindTurbineIcon size={18} />
|
||||
<div style={{
|
||||
fontSize: 6, color: COLOR, marginTop: 1,
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000',
|
||||
whiteSpace: 'nowrap', fontWeight: 700,
|
||||
}}>
|
||||
{wf.name.length > 10 ? wf.name.slice(0, 10) + '..' : wf.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="popup-body-sm" style={{ minWidth: 200 }}>
|
||||
<div className="popup-header" style={{ background: COLOR, color: '#000', gap: 6, padding: '6px 10px' }}>
|
||||
<span style={{ fontSize: 16 }}>🌀</span>
|
||||
<strong>{selected.name}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
|
||||
<span style={{
|
||||
background: STATUS_COLOR[selected.status] || '#666', color: '#fff',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
{selected.status}
|
||||
</span>
|
||||
<span style={{
|
||||
background: COLOR, color: '#000',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
||||
}}>
|
||||
해상풍력
|
||||
</span>
|
||||
<span style={{
|
||||
background: '#333', color: '#ccc',
|
||||
padding: '2px 8px', borderRadius: 3, fontSize: 10,
|
||||
}}>
|
||||
{selected.region}
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-grid" style={{ gap: '2px 12px' }}>
|
||||
<div><span className="popup-label">용량 : </span><strong>{selected.capacityMW} MW</strong></div>
|
||||
<div><span className="popup-label">터빈 : </span><strong>{selected.turbines}기</strong></div>
|
||||
{selected.year && <div><span className="popup-label">준공 : </span><strong>{selected.year}년</strong></div>}
|
||||
<div><span className="popup-label">지역 : </span>{selected.region}</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
||||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,8 @@ import { Marker, Popup, Source, Layer, useMap } from 'react-map-gl/maplibre';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Ship, ShipCategory } from '../../types';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { detectFleet } from '../../utils/fleetDetection';
|
||||
import type { FleetConnection } from '../../utils/fleetDetection';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
@ -19,6 +21,7 @@ const MT_TYPE_COLORS: Record<string, string> = {
|
||||
tanker: 'var(--kcg-ship-tanker)',
|
||||
passenger: 'var(--kcg-ship-passenger)',
|
||||
fishing: 'var(--kcg-ship-fishing)',
|
||||
fishing_gear: '#f97316',
|
||||
pleasure: 'var(--kcg-ship-pleasure)',
|
||||
military: 'var(--kcg-ship-military)',
|
||||
tug_special: 'var(--kcg-ship-tug)',
|
||||
@ -32,6 +35,7 @@ const MT_TYPE_HEX: Record<string, string> = {
|
||||
tanker: '#e74c3c',
|
||||
passenger: '#4caf50',
|
||||
fishing: '#42a5f5',
|
||||
fishing_gear: '#f97316',
|
||||
pleasure: '#e91e8c',
|
||||
military: '#d32f2f',
|
||||
tug_special: '#2e7d32',
|
||||
@ -93,7 +97,7 @@ const FLAG_EMOJI: Record<string, string> = {
|
||||
// icon-size multiplier (symbol layer, base=64px)
|
||||
const SIZE_MAP: Record<ShipCategory, number> = {
|
||||
carrier: 0.32, destroyer: 0.22, warship: 0.22, submarine: 0.18, patrol: 0.16,
|
||||
tanker: 0.16, cargo: 0.16, civilian: 0.14, unknown: 0.12,
|
||||
tanker: 0.16, cargo: 0.16, fishing: 0.14, civilian: 0.14, unknown: 0.12,
|
||||
};
|
||||
|
||||
const MIL_CATEGORIES: ShipCategory[] = ['carrier', 'destroyer', 'warship', 'submarine', 'patrol'];
|
||||
@ -249,11 +253,13 @@ function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
|
||||
|
||||
return (
|
||||
<div className="mb-1.5">
|
||||
<div className="flex gap-0.5 mb-1 rounded bg-kcg-bg p-0.5">
|
||||
<div className="flex mb-1">
|
||||
{hasSPGlobal && (
|
||||
<div
|
||||
className={`flex-1 py-0.5 text-center cursor-pointer rounded transition-all text-[9px] font-semibold ${
|
||||
activeTab === 'spglobal' ? 'bg-[#1565c0] text-white' : 'bg-transparent text-kcg-muted'
|
||||
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
|
||||
activeTab === 'spglobal'
|
||||
? 'border-[#1565c0] text-white bg-white/5'
|
||||
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
|
||||
}`}
|
||||
onClick={() => setActiveTab('spglobal')}
|
||||
>
|
||||
@ -261,8 +267,10 @@ function VesselPhoto({ mmsi, imo, shipImagePath }: VesselPhotoProps) {
|
||||
</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'
|
||||
className={`flex-1 py-1.5 text-center cursor-pointer transition-all text-[12px] font-bold tracking-wide border-b-2 ${
|
||||
activeTab === 'marinetraffic'
|
||||
? 'border-[#1565c0] text-white bg-white/5'
|
||||
: 'border-transparent text-kcg-muted hover:text-kcg-dim hover:bg-white/[0.03]'
|
||||
}`}
|
||||
onClick={() => setActiveTab('marinetraffic')}
|
||||
>
|
||||
@ -396,7 +404,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
mmsi: ship.mmsi,
|
||||
name: ship.name,
|
||||
color: getShipHex(ship),
|
||||
size: SIZE_MAP[ship.category],
|
||||
size: SIZE_MAP[ship.category] ?? 0.12,
|
||||
isMil: isMilitary(ship.category) ? 1 : 0,
|
||||
isKorean: ship.flag === 'KR' ? 1 : 0,
|
||||
isCheonghae: ship.mmsi === '440001981' ? 1 : 0,
|
||||
@ -457,11 +465,50 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
|
||||
const selectedShip = selectedMmsi ? filtered.find(s => s.mmsi === selectedMmsi) ?? null : null;
|
||||
|
||||
// 선단 탐지 (중국어선 선택 시 — 성능 최적화: 근처 선박만 전달)
|
||||
const fleet: FleetConnection | null = useMemo(() => {
|
||||
if (!selectedShip || selectedShip.flag !== 'CN') return null;
|
||||
// 0.2도(~12NM) 이내 선박만 필터링하여 전달
|
||||
const nearby = ships.filter(s =>
|
||||
Math.abs(s.lat - selectedShip.lat) < 0.2 &&
|
||||
Math.abs(s.lng - selectedShip.lng) < 0.2
|
||||
);
|
||||
return detectFleet(selectedShip, nearby);
|
||||
}, [selectedShip, ships]);
|
||||
|
||||
// 선단 연결선 GeoJSON
|
||||
const fleetLineGeoJson = useMemo(() => {
|
||||
if (!fleet) return { type: 'FeatureCollection' as const, features: [] };
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: fleet.members.map(m => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { role: m.role },
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: [
|
||||
[fleet.selectedShip.lng, fleet.selectedShip.lat],
|
||||
[m.ship.lng, m.ship.lat],
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [fleet]);
|
||||
|
||||
// Carrier labels — only a few, so DOM markers are fine
|
||||
const carriers = useMemo(() => filtered.filter(s => s.category === 'carrier'), [filtered]);
|
||||
|
||||
|
||||
|
||||
// 선단 역할별 색상
|
||||
const FLEET_ROLE_COLORS: Record<string, string> = {
|
||||
pair: '#ef4444',
|
||||
carrier: '#f97316',
|
||||
lighting: '#eab308',
|
||||
mothership: '#dc2626',
|
||||
subsidiary: '#6b7280',
|
||||
};
|
||||
|
||||
if (!imageReady) return null;
|
||||
|
||||
return (
|
||||
@ -551,15 +598,53 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Fleet connection lines — 중국어선 클릭 시만 */}
|
||||
{fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && (
|
||||
<Source id="fleet-lines" type="geojson" data={fleetLineGeoJson}>
|
||||
<Layer
|
||||
id="fleet-line-layer"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#ef4444',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [3, 2],
|
||||
'line-opacity': 0.8,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Fleet member markers — 중국어선 클릭 시만 */}
|
||||
{fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => (
|
||||
<Marker key={`fleet-${m.ship.mmsi}`} longitude={m.ship.lng} latitude={m.ship.lat} anchor="center">
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
border: `2px solid ${FLEET_ROLE_COLORS[m.role] || '#ef4444'}`,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 8, color: '#fff', fontWeight: 700,
|
||||
filter: `drop-shadow(0 0 4px ${FLEET_ROLE_COLORS[m.role] || '#ef4444'})`,
|
||||
}}>
|
||||
{m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : '●'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 6, color: FLEET_ROLE_COLORS[m.role] || '#888', textAlign: 'center',
|
||||
textShadow: '0 0 3px #000', fontWeight: 700, marginTop: -2,
|
||||
}}>
|
||||
{m.roleKo} {m.distanceNm.toFixed(1)}NM
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Popup for selected ship */}
|
||||
{selectedShip && (
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} />
|
||||
<ShipPopup ship={selectedShip} onClose={() => setSelectedMmsi(null)} fleet={fleet} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClose: () => void }) {
|
||||
const ShipPopup = memo(function ShipPopup({ ship, onClose, fleet }: { ship: Ship; onClose: () => void; fleet?: FleetConnection | null }) {
|
||||
const { t } = useTranslation('ships');
|
||||
const mtType = getMTType(ship);
|
||||
const color = MT_TYPE_COLORS[mtType] || MT_TYPE_COLORS.unknown;
|
||||
@ -614,7 +699,7 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClo
|
||||
{/* Header — draggable handle */}
|
||||
<div
|
||||
className="ship-popup-header"
|
||||
style={{ background: isMil ? 'var(--kcg-card)' : '#1565c0', cursor: 'grab' }}
|
||||
style={{ background: isMil ? '#1a1a2e' : '#1565c0', cursor: 'grab' }}
|
||||
>
|
||||
{flagEmoji && <span className="text-base leading-none">{flagEmoji}</span>}
|
||||
<strong className="ship-popup-name">{ship.name}</strong>
|
||||
@ -723,6 +808,25 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fleet info (중국어선만) */}
|
||||
{fleet && fleet.members.length > 0 && (
|
||||
<div style={{ borderTop: '1px solid #333', marginTop: 6, paddingTop: 6 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#ef4444', marginBottom: 4 }}>
|
||||
🔗 {fleet.fleetTypeKo} — {fleet.members.length}척 연결
|
||||
</div>
|
||||
{fleet.members.slice(0, 5).map(m => (
|
||||
<div key={m.ship.mmsi} style={{ fontSize: 9, display: 'flex', gap: 4, padding: '2px 0', color: '#ccc' }}>
|
||||
<span style={{ color: '#ef4444', fontWeight: 700, minWidth: 55 }}>{m.roleKo}</span>
|
||||
<span style={{ flex: 1 }}>{m.ship.name || m.ship.mmsi}</span>
|
||||
<span style={{ color: '#f97316' }}>{m.distanceNm.toFixed(1)}NM</span>
|
||||
</div>
|
||||
))}
|
||||
{fleet.members.length > 5 && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>...외 {fleet.members.length - 5}척</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="ship-popup-footer">
|
||||
<span className="ship-popup-timestamp">
|
||||
|
||||
66
frontend/src/data/govBuildings.ts
Normal file
66
frontend/src/data/govBuildings.ts
Normal file
@ -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: '센카쿠 열도 경비, 남서해역 순찰' },
|
||||
];
|
||||
79
frontend/src/data/middleEastFacilities.ts
Normal file
79
frontend/src/data/middleEastFacilities.ts
Normal file
@ -0,0 +1,79 @@
|
||||
// ═══ Middle East Key Facilities (OSINT) ═══
|
||||
// Government HQs, Naval Bases, Missile Sites, Intelligence Centers
|
||||
|
||||
export interface MEFacility {
|
||||
id: string;
|
||||
name: string;
|
||||
nameKo: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: string;
|
||||
flag: string;
|
||||
type: 'naval' | 'military_hq' | 'missile' | 'intelligence' | 'government' | 'radar';
|
||||
description: string;
|
||||
}
|
||||
|
||||
const TYPE_META: Record<string, { label: string; color: string; icon: string }> = {
|
||||
naval: { label: 'Naval Base', color: '#3b82f6', icon: '⚓' },
|
||||
military_hq: { label: 'Military HQ', color: '#ef4444', icon: '⭐' },
|
||||
missile: { label: 'Missile/Air Defense', color: '#dc2626', icon: '🚀' },
|
||||
intelligence: { label: 'Intelligence', color: '#8b5cf6', icon: '🔍' },
|
||||
government: { label: 'Government', color: '#f59e0b', icon: '🏛️' },
|
||||
radar: { label: 'Radar/C2', color: '#06b6d4', icon: '📡' },
|
||||
};
|
||||
|
||||
export { TYPE_META as ME_FACILITY_TYPE_META };
|
||||
|
||||
export const ME_FACILITIES: MEFacility[] = [
|
||||
// ═══ 🇮🇷 이란 (기존 공항/석유 외 추가 시설) ═══
|
||||
{ id: 'IR-F01', name: 'IRGC Navy HQ (Bandar Abbas)', nameKo: 'IRGC 해군사령부 (반다르아바스)', lat: 27.1800, lng: 56.2700, country: 'Iran', flag: '🇮🇷', type: 'naval', description: 'IRGC 해군 사령부, 호르무즈 해협 작전 지휘' },
|
||||
{ id: 'IR-F02', name: 'Jask Naval Base', nameKo: '자스크 해군기지', lat: 25.6500, lng: 57.7700, country: 'Iran', flag: '🇮🇷', type: 'naval', description: '오만만 전진기지, 호르무즈 우회 거점' },
|
||||
{ id: 'IR-F03', name: 'Abu Musa Island Base', nameKo: '아부무사 섬 기지', lat: 25.8700, lng: 55.0300, country: 'Iran', flag: '🇮🇷', type: 'missile', description: '대함미사일·해안포 배치, 해협 봉쇄 거점' },
|
||||
{ id: 'IR-F04', name: 'Qeshm Island IRGC', nameKo: '케슘 섬 IRGC 기지', lat: 26.9500, lng: 56.2700, country: 'Iran', flag: '🇮🇷', type: 'missile', description: 'IRGC 쾌속정·대함미사일 전진배치' },
|
||||
{ id: 'IR-F05', name: 'Kharg Island Air Defense', nameKo: '하르그섬 방공진지', lat: 29.2600, lng: 50.3300, country: 'Iran', flag: '🇮🇷', type: 'missile', description: 'S-300 방공미사일 배치, 수출터미널 방어' },
|
||||
{ id: 'IR-F06', name: 'Chabahar Naval Base', nameKo: '차바하르 해군기지', lat: 25.2900, lng: 60.6400, country: 'Iran', flag: '🇮🇷', type: 'naval', description: '이란 해군 동부 기지, 인도양 진출 거점' },
|
||||
{ id: 'IR-F07', name: 'IRGC Aerospace Force HQ (Tehran)', nameKo: 'IRGC 항공우주군 사령부', lat: 35.7200, lng: 51.4200, country: 'Iran', flag: '🇮🇷', type: 'military_hq', description: '탄도미사일·드론 작전 지휘부' },
|
||||
|
||||
// ═══ 🇺🇸 미군 주요 시설 ═══
|
||||
{ id: 'US-F01', name: 'US 5th Fleet HQ (Bahrain)', nameKo: '미 제5함대 사령부 (바레인)', lat: 26.2100, lng: 50.6200, country: 'Bahrain', flag: '🇺🇸', type: 'naval', description: '미 해군 중부사령부, 페르시아만 작전 지휘' },
|
||||
{ id: 'US-F02', name: 'CENTCOM Forward HQ (Qatar)', nameKo: 'CENTCOM 전방사령부 (카타르)', lat: 25.1200, lng: 51.3200, country: 'Qatar', flag: '🇺🇸', type: 'military_hq', description: '알우데이드 기지 내 중부사령부 전방지휘소' },
|
||||
{ id: 'US-F03', name: 'Camp Arifjan (Kuwait)', nameKo: '아리프잔 기지 (쿠웨이트)', lat: 29.0800, lng: 48.0900, country: 'Kuwait', flag: '🇺🇸', type: 'military_hq', description: '미 육군 중부사령부 전방본부' },
|
||||
{ id: 'US-F04', name: 'NSA Bahrain (SIGINT)', nameKo: 'NSA 바레인 (신호정보)', lat: 26.2300, lng: 50.6000, country: 'Bahrain', flag: '🇺🇸', type: 'intelligence', description: '중동 신호정보 수집 허브' },
|
||||
{ id: 'US-F05', name: 'Diego Garcia (B-2 base)', nameKo: '디에고 가르시아 (B-2 기지)', lat: -7.3133, lng: 72.4111, country: 'BIOT', flag: '🇺🇸', type: 'military_hq', description: 'B-2 스텔스 폭격기 전진배치, 인도양 전략기지' },
|
||||
|
||||
// ═══ 🇮🇱 이스라엘 ═══
|
||||
{ id: 'IL-F01', name: 'IDF General Staff (Tel Aviv)', nameKo: 'IDF 참모본부 (텔아비브)', lat: 32.0700, lng: 34.7900, country: 'Israel', flag: '🇮🇱', type: 'military_hq', description: '이스라엘 군 최고 지휘부, 키르야 기지' },
|
||||
{ id: 'IL-F02', name: 'Haifa Naval Base', nameKo: '하이파 해군기지', lat: 32.8200, lng: 35.0000, country: 'Israel', flag: '🇮🇱', type: 'naval', description: '이스라엘 해군 본부, 잠수함·코르벳함 모항' },
|
||||
{ id: 'IL-F03', name: 'Palmachim AFB (Missile Test)', nameKo: '팔마힘 공군기지 (미사일)', lat: 31.8900, lng: 34.6900, country: 'Israel', flag: '🇮🇱', type: 'missile', description: '제리코 미사일·샤빗 로켓 발사장, Arrow 방공 배치' },
|
||||
{ id: 'IL-F04', name: 'Unit 8200 HQ (Herzliya)', nameKo: '8200부대 본부 (헤르즐리야)', lat: 32.1700, lng: 34.7900, country: 'Israel', flag: '🇮🇱', type: 'intelligence', description: '이스라엘 신호정보(SIGINT) 본부, 사이버전 지휘' },
|
||||
{ id: 'IL-F05', name: 'Dimona Nuclear Center', nameKo: '디모나 핵연구센터', lat: 31.0000, lng: 35.1400, country: 'Israel', flag: '🇮🇱', type: 'missile', description: '핵무기 생산 추정 시설 (네게브 핵연구센터)' },
|
||||
|
||||
// ═══ 🇸🇦 사우디아라비아 ═══
|
||||
{ id: 'SA-F01', name: 'Royal Saudi Naval Forces HQ', nameKo: '사우디 해군사령부 (리야드)', lat: 24.7100, lng: 46.6700, country: 'Saudi Arabia', flag: '🇸🇦', type: 'naval', description: '사우디 왕립 해군 사령부' },
|
||||
{ id: 'SA-F02', name: 'King Abdulaziz Naval Base (Jubail)', nameKo: '주바일 해군기지', lat: 27.0100, lng: 49.6200, country: 'Saudi Arabia', flag: '🇸🇦', type: 'naval', description: '페르시아만 함대 본거지, 동부함대 사령부' },
|
||||
{ id: 'SA-F03', name: 'King Faisal Naval Base (Jeddah)', nameKo: '제다 해군기지', lat: 21.5200, lng: 39.1600, country: 'Saudi Arabia', flag: '🇸🇦', type: 'naval', description: '홍해 함대 본거지, 서부함대 사령부' },
|
||||
{ id: 'SA-F04', name: 'Patriot Battery (Riyadh)', nameKo: '리야드 패트리어트 방공진지', lat: 24.7500, lng: 46.7500, country: 'Saudi Arabia', flag: '🇸🇦', type: 'missile', description: 'PAC-3 패트리어트 방공미사일, 수도 방어' },
|
||||
|
||||
// ═══ 🇦🇪 UAE ═══
|
||||
{ id: 'AE-F01', name: 'UAE Naval HQ (Abu Dhabi)', nameKo: 'UAE 해군사령부 (아부다비)', lat: 24.4400, lng: 54.4200, country: 'UAE', flag: '🇦🇪', type: 'naval', description: 'UAE 해군 본부, Baynunah급 코르벳' },
|
||||
{ id: 'AE-F02', name: 'Fujairah Naval Base', nameKo: '푸자이라 해군기지', lat: 25.1200, lng: 56.3400, country: 'UAE', flag: '🇦🇪', type: 'naval', description: '호르무즈 외부 오만만 해군기지' },
|
||||
{ id: 'AE-F03', name: 'THAAD Battery (Al Dhafra)', nameKo: 'THAAD 배터리 (알다프라)', lat: 24.2500, lng: 54.5500, country: 'UAE', flag: '🇦🇪', type: 'missile', description: '미군 THAAD 고고도방어미사일 배치' },
|
||||
|
||||
// ═══ 🇴🇲 오만 ═══
|
||||
{ id: 'OM-F01', name: 'Said Bin Sultan Naval Base (Muscat)', nameKo: '무스카트 해군기지', lat: 23.6300, lng: 58.5900, country: 'Oman', flag: '🇴🇲', type: 'naval', description: '오만 왕립 해군 본부' },
|
||||
{ id: 'OM-F02', name: 'Duqm Naval Base', nameKo: '두큼 해군기지', lat: 19.6700, lng: 57.7000, country: 'Oman', flag: '🇴🇲', type: 'naval', description: '인도양 전략기지, 영국·미국 공동사용' },
|
||||
|
||||
// ═══ 🇶🇦 카타르 ═══
|
||||
{ id: 'QA-F01', name: 'Qatar Naval Base (Doha)', nameKo: '도하 해군기지', lat: 25.3000, lng: 51.5300, country: 'Qatar', flag: '🇶🇦', type: 'naval', description: '카타르 해군 본부' },
|
||||
|
||||
// ═══ 🇾🇪 예멘 (후티) ═══
|
||||
{ id: 'YE-F01', name: 'Hodeidah Port (Houthi)', nameKo: '호데이다항 (후티)', lat: 14.7980, lng: 42.9540, country: 'Yemen', flag: '🇾🇪', type: 'naval', description: '후티 반군 해상 작전 거점, 홍해 위협' },
|
||||
{ id: 'YE-F02', name: 'Sanaa (Houthi HQ)', nameKo: '사나 (후티 본부)', lat: 15.3694, lng: 44.1910, country: 'Yemen', flag: '🇾🇪', type: 'military_hq', description: '후티 반군 지휘부, 드론·미사일 발사 지휘' },
|
||||
|
||||
// ═══ 🇩🇯 지부티 ═══
|
||||
{ id: 'DJ-F01', name: 'China PLA Support Base', nameKo: '중국 인민해방군 지원기지', lat: 11.5900, lng: 43.1500, country: 'Djibouti', flag: '🇨🇳', type: 'naval', description: '중국 최초 해외군사기지, 아덴만 작전' },
|
||||
{ id: 'DJ-F02', name: 'Camp Lemonnier (US)', nameKo: '레모니에 기지 (미군)', lat: 11.5474, lng: 43.1556, country: 'Djibouti', flag: '🇺🇸', type: 'military_hq', description: '미 아프리카사령부 전진기지, 드론 작전' },
|
||||
|
||||
// ═══ 🇵🇰 파키스탄 ═══
|
||||
{ id: 'PK-F01', name: 'Gwadar Port (China-built)', nameKo: '과다르항 (중국 건설)', lat: 25.1264, lng: 62.3225, country: 'Pakistan', flag: '🇵🇰', type: 'naval', description: 'CPEC 핵심항, 중국 해군 잠재 기지' },
|
||||
];
|
||||
90
frontend/src/data/militaryBases.ts
Normal file
90
frontend/src/data/militaryBases.ts
Normal file
@ -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: '중국 푸젠성 인접 전진기지, 지대함미사일 배치' },
|
||||
];
|
||||
60
frontend/src/data/nkLaunchSites.ts
Normal file
60
frontend/src/data/nkLaunchSites.ts
Normal file
@ -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<string, { label: string; color: string; icon: string }> = {
|
||||
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차 핵실험' },
|
||||
];
|
||||
71
frontend/src/data/nkMissileEvents.ts
Normal file
71
frontend/src/data/nkMissileEvents.ts
Normal file
@ -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 발표',
|
||||
},
|
||||
];
|
||||
@ -183,14 +183,16 @@ export const iranOilFacilities: OilFacility[] = [
|
||||
// ═══ 가스전 (Gas Fields) ═══
|
||||
{
|
||||
id: 'gas-southpars',
|
||||
name: 'South Pars Gas Field',
|
||||
nameKo: '사우스파르스 가스전',
|
||||
name: 'South Pars Gas Field (Phase 3-6)',
|
||||
nameKo: '사우스파르스 가스전 (3·4·5·6광구)',
|
||||
lat: 27.0000, lng: 52.0000,
|
||||
type: 'gasfield',
|
||||
capacityMcfd: 20_000,
|
||||
reservesTcf: 500,
|
||||
operator: 'Pars Oil & Gas Co.',
|
||||
description: '세계 최대 가스전 (카타르 노스돔과 공유). 매장량 500조 입방피트. 이란 가스 수출 핵심.',
|
||||
description: '세계 최대 가스전 (카타르 노스돔과 공유). 2026.3.18 이스라엘 공습으로 3·4·5·6광구 화재·가동 중단.',
|
||||
damaged: true,
|
||||
damagedAt: Date.UTC(2026, 2, 18, 0, 0, 0),
|
||||
},
|
||||
{
|
||||
id: 'gas-northpars',
|
||||
@ -364,6 +366,18 @@ export const iranOilFacilities: OilFacility[] = [
|
||||
operator: 'EWA Bahrain',
|
||||
description: '바레인 주요 수자원. 일 9,000만 갤런. 국가 물 수요 80% 담당.',
|
||||
},
|
||||
{
|
||||
id: 'gas-ras-laffan',
|
||||
name: 'Ras Laffan LNG/Gas Complex (Qatar)',
|
||||
nameKo: '라스라판 LNG/가스 단지 (카타르)',
|
||||
lat: 25.9200, lng: 51.5400,
|
||||
type: 'gasfield',
|
||||
capacityMcfd: 14_000,
|
||||
operator: 'QatarEnergy',
|
||||
description: '세계 최대 LNG 수출기지. 2026.3.18 이란 보복 공격으로 피격. 사우스파르스 공습에 대한 보복.',
|
||||
damaged: true,
|
||||
damagedAt: Date.UTC(2026, 2, 18, 6, 0, 0),
|
||||
},
|
||||
{
|
||||
id: 'desal-ras-laffan',
|
||||
name: 'Ras Laffan Desalination Plant',
|
||||
|
||||
76
frontend/src/data/ports.ts
Normal file
76
frontend/src/data/ports.ts
Normal file
@ -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' },
|
||||
];
|
||||
36
frontend/src/data/windFarms.ts
Normal file
36
frontend/src/data/windFarms.ts
Normal file
@ -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 },
|
||||
];
|
||||
@ -13,6 +13,7 @@ interface UseKoreaDataArgs {
|
||||
isLive: boolean;
|
||||
hiddenAcCategories: Set<string>;
|
||||
hiddenShipCategories: Set<string>;
|
||||
hiddenNationalities: Set<string>;
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
@ -26,6 +27,8 @@ interface UseKoreaDataResult {
|
||||
koreaKoreanShips: Ship[];
|
||||
koreaChineseShips: Ship[];
|
||||
shipsByCategory: Record<string, number>;
|
||||
shipsByNationality: Record<string, number>;
|
||||
fishingByNationality: Record<string, number>;
|
||||
aircraftByCategory: Record<string, number>;
|
||||
militaryCount: number;
|
||||
}
|
||||
@ -33,11 +36,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<Aircraft[]>([]);
|
||||
@ -144,8 +158,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 +178,26 @@ export function useKoreaData({
|
||||
return counts;
|
||||
}, [ships]);
|
||||
|
||||
const shipsByNationality = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
const nat = getNationalityGroup(s.flag);
|
||||
counts[nat] = (counts[nat] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
|
||||
const fishingByNationality = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of ships) {
|
||||
if (getMarineTrafficCategory(s.typecode, s.category) !== 'fishing') continue;
|
||||
const flag = s.flag || 'unknown';
|
||||
const group = flag === 'CN' ? 'CN' : flag === 'KR' ? 'KR' : flag === 'JP' ? 'JP' : 'other';
|
||||
counts[group] = (counts[group] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [ships]);
|
||||
|
||||
// Korea aircraft stats
|
||||
const aircraftByCategory = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
@ -185,6 +222,8 @@ export function useKoreaData({
|
||||
koreaKoreanShips,
|
||||
koreaChineseShips,
|
||||
shipsByCategory,
|
||||
shipsByNationality,
|
||||
fishingByNationality,
|
||||
aircraftByCategory,
|
||||
militaryCount,
|
||||
};
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"tanker": "Tanker",
|
||||
"passenger": "Passenger",
|
||||
"fishing": "Fishing",
|
||||
"fishing_gear": "Gear/Net",
|
||||
"military": "Military",
|
||||
"tug_special": "Tug/Special",
|
||||
"high_speed": "High Speed",
|
||||
@ -105,8 +106,8 @@
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"status": {
|
||||
"anchored": "Anchored",
|
||||
"underway": "Underway",
|
||||
"anchored": "Anch",
|
||||
"underway": "Under",
|
||||
"moored": "Moored",
|
||||
"total": "Total"
|
||||
},
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"tanker": "유조선",
|
||||
"passenger": "여객선",
|
||||
"fishing": "어선",
|
||||
"fishing_gear": "어구/어망",
|
||||
"military": "군함",
|
||||
"tug_special": "예인선",
|
||||
"high_speed": "고속선",
|
||||
|
||||
@ -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' },
|
||||
];
|
||||
|
||||
@ -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) ═══
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// ═══ 대한민국 해양경찰청 시설 위치 ═══
|
||||
// Korea Coast Guard (KCG) facilities
|
||||
|
||||
export type CoastGuardType = 'hq' | 'regional' | 'station' | 'substation' | 'vts';
|
||||
export type CoastGuardType = 'hq' | 'regional' | 'station' | 'substation' | 'vts' | 'navy';
|
||||
|
||||
export interface CoastGuardFacility {
|
||||
id: number;
|
||||
@ -17,6 +17,7 @@ const TYPE_LABEL: Record<CoastGuardType, string> = {
|
||||
station: '해양경찰서',
|
||||
substation: '파출소',
|
||||
vts: 'VTS센터',
|
||||
navy: '해군부대',
|
||||
};
|
||||
|
||||
export { TYPE_LABEL as CG_TYPE_LABEL };
|
||||
@ -79,4 +80,16 @@ export const COAST_GUARD_FACILITIES: CoastGuardFacility[] = [
|
||||
{ id: 112, name: '동해VTS', type: 'vts', lat: 37.5300, lng: 129.1200 },
|
||||
{ id: 113, name: '속초VTS', type: 'vts', lat: 38.2100, lng: 128.5930 },
|
||||
{ id: 114, name: '제주VTS', type: 'vts', lat: 33.5150, lng: 126.5400 },
|
||||
|
||||
// ═══ 🇰🇷 대한민국 해군부대 ═══
|
||||
{ id: 200, name: '해군작전사령부 (부산)', type: 'navy', lat: 35.0800, lng: 129.0800 },
|
||||
{ id: 201, name: '제1함대사령부 (동해)', type: 'navy', lat: 37.5100, lng: 129.1100 },
|
||||
{ id: 202, name: '제2함대사령부 (평택)', type: 'navy', lat: 36.9700, lng: 126.8200 },
|
||||
{ id: 203, name: '제3함대사령부 (목포)', type: 'navy', lat: 34.7900, lng: 126.3800 },
|
||||
{ id: 204, name: '제주기지전대 (제주)', type: 'navy', lat: 33.2400, lng: 126.5700 },
|
||||
{ id: 205, name: '진해군항 (창원)', type: 'navy', lat: 35.1300, lng: 128.6700 },
|
||||
{ id: 206, name: '해군사관학교 (진해)', type: 'navy', lat: 35.1400, lng: 128.6900 },
|
||||
{ id: 207, name: '제5성분전단 (포항)', type: 'navy', lat: 36.0200, lng: 129.3700 },
|
||||
{ id: 208, name: '제6항공전단 (포항)', type: 'navy', lat: 35.9900, lng: 129.4200 },
|
||||
{ id: 209, name: '제2연평해전 전적지 (NLL)', type: 'navy', lat: 37.6600, lng: 125.6200 },
|
||||
];
|
||||
|
||||
@ -83,7 +83,7 @@ const SPG_VESSEL_TYPE_MAP: Record<string, ShipCategory> = {
|
||||
'Tanker': 'tanker',
|
||||
'Passenger': 'civilian',
|
||||
'Tug': 'civilian',
|
||||
'Fishing': 'civilian',
|
||||
'Fishing': 'fishing',
|
||||
'Pilot Boat': 'civilian',
|
||||
'Tender': 'civilian',
|
||||
'Vessel': 'civilian',
|
||||
@ -666,6 +666,11 @@ function aisTypeToVesselType(shipTy: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 어구/어망 선박명 패턴:
|
||||
// "이름_숫자_" (예: "HAEJIN_123_", "金龙_45_")
|
||||
// "이름%" (예: "HAEJIN%", "金龙123%")
|
||||
const FISHING_GEAR_NAME_PATTERN = /^.+_\d+_$|%$/;
|
||||
|
||||
function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
|
||||
const name = (d.shipNm || '').trim();
|
||||
const mmsi = String(d.mmsi || '');
|
||||
@ -676,7 +681,66 @@ function parseSignalBatchVessel(d: RecentPositionDetailDto): Ship {
|
||||
// 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);
|
||||
let vesselType = isNumeric ? aisTypeToVesselType(rawShipTy) : (rawShipTy || undefined);
|
||||
|
||||
// shipKindCode 보조 분류 (signal-batch 한국 데이터)
|
||||
// 해양수산부 선종코드: 000020~000029 어선, 000023 화물, 000024 유조, 000030 어선
|
||||
const kindCode = (d.shipKindCode || '').trim();
|
||||
const kindNum = parseInt(kindCode, 10);
|
||||
if (!vesselType || vesselType === 'N/A' || vesselType === 'Vessel') {
|
||||
if (kindNum >= 20 && kindNum <= 22) vesselType = 'Fishing';
|
||||
else if (kindNum === 30 || kindNum === 31) vesselType = 'Fishing';
|
||||
else if (kindNum >= 10 && kindNum <= 15) vesselType = 'Fishing'; // 소형 어선류
|
||||
}
|
||||
|
||||
// DEBUG: kindCode 분포 집계
|
||||
if (typeof window !== 'undefined') {
|
||||
const w = window as Window & { __kindCodeMap?: Record<string, number>; __kindCodeLogged?: boolean };
|
||||
if (!w.__kindCodeMap) w.__kindCodeMap = {};
|
||||
const km = w.__kindCodeMap;
|
||||
const key = `${kindCode}|${rawShipTy}`;
|
||||
km[key] = (km[key] || 0) + 1;
|
||||
if (!w.__kindCodeLogged && Object.keys(km).length > 20) {
|
||||
w.__kindCodeLogged = true;
|
||||
console.log('[SHIP DEBUG] kindCode|shipTy distribution:', JSON.stringify(
|
||||
Object.entries(km).sort((a, b) => b[1] - a[1]).slice(0, 30)
|
||||
));
|
||||
}
|
||||
}
|
||||
if (!vesselType || vesselType === 'Vessel' || vesselType === 'N/A') {
|
||||
if (kindCode.includes('어선') || kindCode.toLowerCase().includes('fish')) {
|
||||
vesselType = 'Fishing';
|
||||
} else if (kindCode.includes('화물') || kindCode.toLowerCase().includes('cargo')) {
|
||||
vesselType = 'Cargo';
|
||||
} else if (kindCode.includes('유조') || kindCode.toLowerCase().includes('tanker')) {
|
||||
vesselType = 'Tanker';
|
||||
} else if (kindCode.includes('여객') || kindCode.toLowerCase().includes('passenger')) {
|
||||
vesselType = 'Passenger';
|
||||
} else if (kindCode.includes('예인') || kindCode.toLowerCase().includes('tug')) {
|
||||
vesselType = 'Tug';
|
||||
}
|
||||
}
|
||||
|
||||
// 어구/어망 판별: 선박명이 "이름_숫자_" 패턴
|
||||
if (FISHING_GEAR_NAME_PATTERN.test(name)) {
|
||||
vesselType = 'FishingGear';
|
||||
}
|
||||
|
||||
// 선박명 기반 어선 추정 (어구로 이미 분류된 것은 건너뜀)
|
||||
if (vesselType !== 'FishingGear' && (!vesselType || vesselType === 'Vessel' || vesselType === 'N/A')) {
|
||||
const nm = name.toLowerCase();
|
||||
// 중국 어선명 패턴: 省略号+渔+番号 (예: 鲁荣渔1234, 浙象渔05678)
|
||||
if (/[鲁浙闽粤琼桂辽冀津沪苏].*渔/.test(name) || nm.includes('渔')) {
|
||||
vesselType = 'Fishing';
|
||||
}
|
||||
// AIS shipTy=0 이지만 중국 국적(412~416 MMSI) + 소형선(<50m)이면 어선 가능성 높음
|
||||
if (!vesselType && flag === 'CN' && d.length != null && d.length > 0 && d.length < 50) {
|
||||
// 소형 중국선 중 화물/유조가 아닌 것은 어선 가능성
|
||||
if (rawShipTy === '0' || rawShipTy === '' || rawShipTy === '30') {
|
||||
vesselType = 'Fishing';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing classification: name pattern → vesselType string → MMSI prefix
|
||||
const category = classifyShip(name, mmsi, vesselType);
|
||||
|
||||
@ -79,7 +79,7 @@ export interface SatellitePosition {
|
||||
}
|
||||
|
||||
// Ship tracking (AIS)
|
||||
export type ShipCategory = 'warship' | 'carrier' | 'destroyer' | 'submarine' | 'cargo' | 'tanker' | 'patrol' | 'civilian' | 'unknown';
|
||||
export type ShipCategory = 'warship' | 'carrier' | 'destroyer' | 'submarine' | 'cargo' | 'tanker' | 'patrol' | 'fishing' | 'civilian' | 'unknown';
|
||||
|
||||
export interface Ship {
|
||||
mmsi: string; // Maritime Mobile Service Identity
|
||||
@ -142,6 +142,7 @@ export interface LayerVisibility {
|
||||
airports: boolean;
|
||||
sensorCharts: boolean;
|
||||
oilFacilities: boolean;
|
||||
meFacilities: boolean;
|
||||
militaryOnly: boolean;
|
||||
}
|
||||
|
||||
|
||||
241
frontend/src/utils/fishingAnalysis.ts
Normal file
241
frontend/src/utils/fishingAnalysis.ts
Normal file
@ -0,0 +1,241 @@
|
||||
// ═══ 중국 어선 조업 분석 — GC-KCG-2026-001 보고서 기반 ═══
|
||||
// 한중어업협정 허가현황 (2026.01.06, 906척) + GB/T 5147-2003 어구 분류
|
||||
|
||||
import type { Ship } from '../types';
|
||||
|
||||
/**
|
||||
* 중국 허가 업종 코드 (허가번호 접두사)
|
||||
* PT(C21): 2척식저인망 본선 323척 + 부속선(PT-S) 323척
|
||||
* OT(C22): 1척식저인망 13척
|
||||
* PS(C23): 위망(선망) 16척 (宁波海裕 단일법인)
|
||||
* GN(C25): 유망(유자망) 200척
|
||||
* FC: 운반선 31척
|
||||
*/
|
||||
export type FishingGearType = 'trawl_pair' | 'trawl_single' | 'gillnet' | 'stow_net' | 'purse_seine' | 'carrier' | 'unknown';
|
||||
|
||||
export interface FishingAnalysis {
|
||||
gearType: FishingGearType;
|
||||
gearTypeKo: string;
|
||||
permitCode: string; // PT, OT, GN, PS, FC
|
||||
gbCode: string; // GB/T 5147-2003 코드
|
||||
isOperating: boolean;
|
||||
operatingStatusKo: string;
|
||||
confidence: number;
|
||||
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
riskReason: string;
|
||||
}
|
||||
|
||||
/** 업종별 메타데이터 */
|
||||
const GEAR_META: Record<FishingGearType, {
|
||||
ko: string; icon: string; color: string;
|
||||
permitCode: string; gbCode: string;
|
||||
speedRange: [number, number]; aiConfidence: string;
|
||||
}> = {
|
||||
trawl_pair: { ko: '2척식 저인망(PT)', icon: '🔻', color: '#ef4444', permitCode: 'PT', gbCode: 'TDS', speedRange: [2, 5], aiConfidence: '89~95%' },
|
||||
trawl_single: { ko: '1척식 저인망(OT)', icon: '🔻', color: '#dc2626', permitCode: 'OT', gbCode: 'TDD', speedRange: [2, 5], aiConfidence: '89~92%' },
|
||||
gillnet: { ko: '유자망(GN)', icon: '🔲', color: '#f97316', permitCode: 'GN', gbCode: 'CLD', speedRange: [0, 2], aiConfidence: '74~80%' },
|
||||
stow_net: { ko: '안강망(Z)', icon: '🪤', color: '#eab308', permitCode: '-', gbCode: 'ZD', speedRange: [0, 1], aiConfidence: '70~78%' },
|
||||
purse_seine: { ko: '위망/선망(PS)', icon: '🔄', color: '#3b82f6', permitCode: 'PS', gbCode: 'WDD', speedRange: [3, 10], aiConfidence: '94~97%' },
|
||||
carrier: { ko: '운반선(FC)', icon: '🚢', color: '#6b7280', permitCode: 'FC', gbCode: '-', speedRange: [0, 12], aiConfidence: '-' },
|
||||
unknown: { ko: '미분류', icon: '🐟', color: '#9ca3af', permitCode: '-', gbCode: '-', speedRange: [0, 0], aiConfidence: '-' },
|
||||
};
|
||||
|
||||
export { GEAR_META as GEAR_LABELS };
|
||||
|
||||
/**
|
||||
* 특정어업수역 정의 (한중어업협정)
|
||||
*/
|
||||
const _FISHING_ZONES = {
|
||||
I: { name: '수역Ⅰ(동해)', lngMin: 128.86, lngMax: 131.67, latMin: 35.65, latMax: 38.25, allowed: ['PS', 'FC'] },
|
||||
II: { name: '수역Ⅱ(남해)', lngMin: 126.00, lngMax: 128.89, latMin: 32.18, latMax: 34.34, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
|
||||
III:{ name: '수역Ⅲ(서남해)', lngMin: 124.01, lngMax: 126.08, latMin: 32.18, latMax: 35.00, allowed: ['PT', 'OT', 'GN', 'PS', 'FC'] },
|
||||
IV: { name: '수역Ⅳ(서해)', lngMin: 124.13, lngMax: 125.85, latMin: 35.00, latMax: 37.00, allowed: ['GN', 'PS', 'FC'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* 업종별 허가 기간 (월/일)
|
||||
*/
|
||||
const PERMIT_PERIODS: Record<string, { periods: [number, number, number, number][]; label: string }> = {
|
||||
PT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
|
||||
OT: { periods: [[1,1, 4,15], [10,16, 12,31]], label: '1/1~4/15, 10/16~12/31' },
|
||||
GN: { periods: [[2,1, 6,1], [9,1, 12,31]], label: '2/1~6/1, 9/1~12/31' },
|
||||
PS: { periods: [[1,1, 12,31]], label: '연중' },
|
||||
FC: { periods: [[1,1, 12,31]], label: '연중' },
|
||||
};
|
||||
|
||||
function isInPermitPeriod(permitCode: string, date: Date): boolean {
|
||||
const pp = PERMIT_PERIODS[permitCode];
|
||||
if (!pp) return false;
|
||||
const m = date.getMonth() + 1;
|
||||
const d = date.getDate();
|
||||
const dayOfYear = m * 100 + d;
|
||||
return pp.periods.some(([m1, d1, m2, d2]) => dayOfYear >= m1 * 100 + d1 && dayOfYear <= m2 * 100 + d2);
|
||||
}
|
||||
|
||||
/**
|
||||
* AIS 신호 기반 중국 어선 조업 분석 (보고서 4장 기반)
|
||||
*
|
||||
* 트롤(PT/OT): 2~5kn, Lawn-mowing 지그재그, 방향변화 <30°
|
||||
* 자망(GN): 0~2kn 정지·재방문, AIS OFF 빈번
|
||||
* 선망(PS): 8~10kn→3kn 급전환, 원형 궤적
|
||||
* 운반선(FC): 환적 패턴 (0.5NM 접근 + 2kn이하 + 30분)
|
||||
*/
|
||||
export function analyzeFishing(ship: Ship): FishingAnalysis {
|
||||
const speed = ship.speed;
|
||||
const status = ship.status?.toLowerCase() || '';
|
||||
const isAnchored = status.includes('anchor') || status.includes('moor');
|
||||
|
||||
// 방향 변화율 (trail 기반)
|
||||
let headingVariance = 0;
|
||||
if (ship.trail && ship.trail.length >= 3) {
|
||||
const pts = ship.trail.slice(-6);
|
||||
const headings: number[] = [];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dlng = pts[i][1] - pts[i - 1][1];
|
||||
const dlat = pts[i][0] - pts[i - 1][0];
|
||||
headings.push(Math.atan2(dlng, dlat) * 180 / Math.PI);
|
||||
}
|
||||
if (headings.length >= 2) {
|
||||
const diffs = headings.slice(1).map((h, i) => {
|
||||
let d = Math.abs(h - headings[i]);
|
||||
if (d > 180) d = 360 - d;
|
||||
return d;
|
||||
});
|
||||
headingVariance = diffs.reduce((a, b) => a + b, 0) / diffs.length;
|
||||
}
|
||||
}
|
||||
|
||||
let gearType: FishingGearType = 'unknown';
|
||||
let isOperating = false;
|
||||
let operatingStatusKo = '이동 중';
|
||||
let confidence = 0.3;
|
||||
|
||||
// === 패턴 매칭 (보고서 4.1~4.3 기준) ===
|
||||
|
||||
if (isAnchored || speed > 12) {
|
||||
// 정박 또는 고속 이동 → 비조업
|
||||
gearType = 'unknown';
|
||||
isOperating = false;
|
||||
operatingStatusKo = isAnchored ? '정박 중' : '고속 이동';
|
||||
confidence = 0.8;
|
||||
|
||||
} else if (speed >= 8 && speed <= 10 && headingVariance > 30) {
|
||||
// 선망(PS) 포위 단계: 8~10kn + 원형 궤적
|
||||
gearType = 'purse_seine';
|
||||
isOperating = true;
|
||||
operatingStatusKo = '선망 포위 중 (8~10kn 원형)';
|
||||
confidence = 0.94;
|
||||
|
||||
} else if (speed <= 3 && headingVariance > 40) {
|
||||
// 선망(PS) 죔줄 단계: 3kn 이하 + 이전 고속→저속 급전환
|
||||
gearType = 'purse_seine';
|
||||
isOperating = true;
|
||||
operatingStatusKo = '선망 죔줄 조임 중 (<3kn)';
|
||||
confidence = 0.85;
|
||||
|
||||
} else if (speed >= 2 && speed <= 5 && headingVariance < 30) {
|
||||
// 트롤(PT/OT): 2~5kn + 완만한 방향 (Lawn-mowing)
|
||||
gearType = headingVariance < 15 ? 'trawl_pair' : 'trawl_single';
|
||||
isOperating = true;
|
||||
operatingStatusKo = `트롤 예인 중 (${speed.toFixed(1)}kn 지그재그)`;
|
||||
confidence = headingVariance < 15 ? 0.92 : 0.89;
|
||||
|
||||
} else if (speed < 2) {
|
||||
// 0~2kn 극저속 → 조업 중 or 정박 대기
|
||||
if (isAnchored || speed < 0.3) {
|
||||
// 완전 정지/정박 → 비조업 (대기 중)
|
||||
gearType = 'unknown';
|
||||
isOperating = false;
|
||||
operatingStatusKo = speed < 0.3 ? '정지/대기 중' : '정박 중';
|
||||
confidence = 0.7;
|
||||
} else if (speed >= 0.3 && speed < 1 && headingVariance < 8) {
|
||||
// 극저속 + 방향 변화 거의 없음 → 안강망 (조류 이용 수동 어구)
|
||||
gearType = 'stow_net';
|
||||
isOperating = true;
|
||||
operatingStatusKo = '안강망 조업 추정 (조류 이용)';
|
||||
confidence = 0.65;
|
||||
} else if (speed >= 0.3 && speed < 2) {
|
||||
// 저속 이동 → 자망 투망/양망
|
||||
gearType = 'gillnet';
|
||||
isOperating = true;
|
||||
operatingStatusKo = speed < 1 ? '자망 투하/대기 중' : '자망 양망 중 (극저속)';
|
||||
confidence = 0.72;
|
||||
}
|
||||
|
||||
} else if (speed >= 5 && speed < 8) {
|
||||
// 중속 이동 → 조업지 이동 또는 운반선
|
||||
gearType = 'carrier';
|
||||
isOperating = false;
|
||||
operatingStatusKo = '이동 중 (조업지 이동 추정)';
|
||||
confidence = 0.5;
|
||||
}
|
||||
|
||||
// === 위반 위험도 판별 (보고서 5장 기반) ===
|
||||
const now = new Date();
|
||||
const meta = GEAR_META[gearType];
|
||||
let riskLevel: FishingAnalysis['riskLevel'] = 'LOW';
|
||||
let riskReason = '정상 범위';
|
||||
|
||||
// 휴어기 체크
|
||||
if (meta.permitCode !== '-' && !isInPermitPeriod(meta.permitCode, now)) {
|
||||
riskLevel = 'CRITICAL';
|
||||
riskReason = `휴어기 조업 의심 (${meta.permitCode} 허가기간: ${PERMIT_PERIODS[meta.permitCode]?.label})`;
|
||||
}
|
||||
|
||||
// AIS 신호 오래된 경우 (다크베셀 의심)
|
||||
const aisAge = Date.now() - ship.lastSeen;
|
||||
if (aisAge > 6 * 3600_000) {
|
||||
riskLevel = 'HIGH';
|
||||
riskReason = `AIS 공백 ${Math.round(aisAge / 3600_000)}시간 — 다크베셀 의심`;
|
||||
}
|
||||
|
||||
// 자망 + 조업 중 → AIS 차단 가능성 높음
|
||||
if (gearType === 'gillnet' && isOperating) {
|
||||
if (riskLevel === 'LOW') {
|
||||
riskLevel = 'MEDIUM';
|
||||
riskReason = '유자망 조업 중 — AIS 차단 주의 업종';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gearType,
|
||||
gearTypeKo: meta.ko,
|
||||
permitCode: meta.permitCode,
|
||||
gbCode: meta.gbCode,
|
||||
isOperating,
|
||||
operatingStatusKo,
|
||||
confidence,
|
||||
riskLevel,
|
||||
riskReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 중국 어선 조업 통계 집계
|
||||
*/
|
||||
export function aggregateFishingStats(ships: Ship[]) {
|
||||
// 중국 어선(fishing 카테고리)만 대상
|
||||
const chineseFishing = ships.filter(s => s.flag === 'CN' && s.category === 'fishing');
|
||||
const results = chineseFishing.map(s => ({ ship: s, analysis: analyzeFishing(s) }));
|
||||
|
||||
const operating = results.filter(r => r.analysis.isOperating);
|
||||
const byGear: Record<FishingGearType, number> = {
|
||||
trawl_pair: 0, trawl_single: 0, gillnet: 0, stow_net: 0, purse_seine: 0, carrier: 0, unknown: 0,
|
||||
};
|
||||
for (const r of operating) {
|
||||
byGear[r.analysis.gearType]++;
|
||||
}
|
||||
|
||||
const critical = results.filter(r => r.analysis.riskLevel === 'CRITICAL').length;
|
||||
const high = results.filter(r => r.analysis.riskLevel === 'HIGH').length;
|
||||
|
||||
return {
|
||||
total: chineseFishing.length,
|
||||
operating: operating.length,
|
||||
idle: chineseFishing.length - operating.length,
|
||||
byGear,
|
||||
critical,
|
||||
high,
|
||||
details: results,
|
||||
};
|
||||
}
|
||||
157
frontend/src/utils/fleetDetection.ts
Normal file
157
frontend/src/utils/fleetDetection.ts
Normal file
@ -0,0 +1,157 @@
|
||||
// ═══ 중국어선 선단(Fleet) 탐지 — GC-KCG-2026-001 기반 ═══
|
||||
|
||||
import type { Ship } from '../types';
|
||||
import { getMarineTrafficCategory } from './marineTraffic';
|
||||
|
||||
export type FleetRole = 'mothership' | 'subsidiary' | 'carrier' | 'lighting' | 'pair';
|
||||
|
||||
export interface FleetMember {
|
||||
ship: Ship;
|
||||
role: FleetRole;
|
||||
roleKo: string;
|
||||
distanceNm: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FleetConnection {
|
||||
selectedShip: Ship;
|
||||
members: FleetMember[];
|
||||
fleetType: 'trawl_pair' | 'purse_seine_fleet' | 'transship' | 'unknown';
|
||||
fleetTypeKo: string;
|
||||
}
|
||||
|
||||
/** 두 지점 사이 거리(NM) */
|
||||
function distNm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 3440.065; // 지구 반경 (해리)
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 중국어선 주변의 선단 구성을 탐지
|
||||
*
|
||||
* 보고서 기준:
|
||||
* - PT 2척식 저인망: 본선+부속선 3NM 이내, 유사 속도(2~5kn), 유사 방향
|
||||
* - PS 위망 선단: 3척+ 클러스터, 모선+운반선+조명선
|
||||
* - FC 운반선 환적: 0.5NM 이내 접근, 양쪽 2kn 이하
|
||||
*/
|
||||
export function detectFleet(selectedShip: Ship, allShips: Ship[]): FleetConnection | null {
|
||||
if (selectedShip.flag !== 'CN') return null;
|
||||
|
||||
const _mtCat = getMarineTrafficCategory(selectedShip.typecode, selectedShip.category);
|
||||
const members: FleetMember[] = [];
|
||||
|
||||
// 주변 중국 선박 탐색 (10NM 반경)
|
||||
const nearby = allShips.filter(s =>
|
||||
s.mmsi !== selectedShip.mmsi &&
|
||||
s.flag === 'CN' &&
|
||||
distNm(selectedShip.lat, selectedShip.lng, s.lat, s.lng) < 10
|
||||
);
|
||||
|
||||
for (const s of nearby) {
|
||||
const d = distNm(selectedShip.lat, selectedShip.lng, s.lat, s.lng);
|
||||
const sCat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
const speedDiff = Math.abs(selectedShip.speed - s.speed);
|
||||
let headingDiff = Math.abs(selectedShip.heading - s.heading);
|
||||
if (headingDiff > 180) headingDiff = 360 - headingDiff;
|
||||
|
||||
// === PT 본선-부속선 쌍 탐지 ===
|
||||
// 3NM 이내 + 유사 속도(차이 1kn 미만) + 유사 방향(20° 미만) + 둘 다 2~5kn
|
||||
if (d < 3 && speedDiff < 1 && headingDiff < 20 &&
|
||||
selectedShip.speed >= 2 && selectedShip.speed <= 5 &&
|
||||
s.speed >= 2 && s.speed <= 5) {
|
||||
members.push({
|
||||
ship: s,
|
||||
role: 'pair',
|
||||
roleKo: '부속선 (PT-S)',
|
||||
distanceNm: d,
|
||||
reason: `속도 ${s.speed.toFixed(1)}kn, 방향차 ${headingDiff.toFixed(0)}°, 거리 ${d.toFixed(1)}NM`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// === FC 운반선 환적 탐지 ===
|
||||
// 0.5NM 이내 + 양쪽 2kn 이하
|
||||
if (d < 0.5 && selectedShip.speed <= 2 && s.speed <= 2) {
|
||||
const isCarrier = sCat === 'cargo' || sCat === 'unspecified' || s.name.includes('运') || s.name.includes('冷');
|
||||
if (isCarrier) {
|
||||
members.push({
|
||||
ship: s,
|
||||
role: 'carrier',
|
||||
roleKo: '운반선 (FC)',
|
||||
distanceNm: d,
|
||||
reason: `환적 의심 — ${d.toFixed(2)}NM, 양쪽 저속`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// === PS 선단 멤버 탐지 ===
|
||||
// 2NM 이내 중국어선 클러스터
|
||||
if (d < 2 && sCat === 'fishing') {
|
||||
// 속도 차이로 역할 추정
|
||||
if (s.speed < 1 && selectedShip.speed > 5) {
|
||||
members.push({
|
||||
ship: s,
|
||||
role: 'lighting',
|
||||
roleKo: '조명선',
|
||||
distanceNm: d,
|
||||
reason: `정지 중 — 집어등 추정`,
|
||||
});
|
||||
} else {
|
||||
members.push({
|
||||
ship: s,
|
||||
role: 'subsidiary',
|
||||
roleKo: '선단 멤버',
|
||||
distanceNm: d,
|
||||
reason: `${d.toFixed(1)}NM, ${s.speed.toFixed(1)}kn`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// === 일반 근접 중국 선박 (5NM 이내) ===
|
||||
if (d < 5 && (sCat === 'fishing' || sCat === 'unspecified')) {
|
||||
members.push({
|
||||
ship: s,
|
||||
role: 'subsidiary',
|
||||
roleKo: '인근 어선',
|
||||
distanceNm: d,
|
||||
reason: `${d.toFixed(1)}NM`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (members.length === 0) return null;
|
||||
|
||||
// 선단 유형 판별
|
||||
const hasPair = members.some(m => m.role === 'pair');
|
||||
const hasCarrier = members.some(m => m.role === 'carrier');
|
||||
const hasLighting = members.some(m => m.role === 'lighting');
|
||||
|
||||
let fleetType: FleetConnection['fleetType'] = 'unknown';
|
||||
let fleetTypeKo = '인근 선박 그룹';
|
||||
|
||||
if (hasPair) {
|
||||
fleetType = 'trawl_pair';
|
||||
fleetTypeKo = '2척식 저인망 (본선·부속선)';
|
||||
} else if (hasCarrier) {
|
||||
fleetType = 'transship';
|
||||
fleetTypeKo = '환적 의심 (운반선 접근)';
|
||||
} else if (hasLighting || members.length >= 3) {
|
||||
fleetType = 'purse_seine_fleet';
|
||||
fleetTypeKo = '위망 선단 (모선·운반·조명)';
|
||||
}
|
||||
|
||||
// 거리순 정렬, 최대 10개
|
||||
members.sort((a, b) => a.distanceNm - b.distanceNm);
|
||||
|
||||
return {
|
||||
selectedShip,
|
||||
members: members.slice(0, 10),
|
||||
fleetType,
|
||||
fleetTypeKo,
|
||||
};
|
||||
}
|
||||
@ -1,20 +1,48 @@
|
||||
// MarineTraffic-style ship classification
|
||||
// Maps S&P STAT5CODE prefixes, VesselType strings, and custom typecodes to MT categories
|
||||
// AIS Ship Type Number → category (ITU-R M.1371-5 Table 50)
|
||||
// 20: Wing in ground, 30: Fishing, 31-32: Towing, 33: Dredging, 34: Diving ops
|
||||
// 35: Military, 36: Sailing, 37: Pleasure, 40-49: High speed, 50: Pilot
|
||||
// 60-69: Passenger, 70-79: Cargo, 80-89: Tanker, 90-99: Other
|
||||
function classifyAisShipType(code: string): string | null {
|
||||
const num = parseInt(code, 10);
|
||||
if (isNaN(num)) return null;
|
||||
if (num === 30) return 'fishing';
|
||||
if (num >= 31 && num <= 34) return 'tug_special';
|
||||
if (num === 35) return 'military';
|
||||
if (num === 36) return 'pleasure';
|
||||
if (num === 37) return 'pleasure';
|
||||
if (num >= 40 && num <= 49) return 'high_speed';
|
||||
if (num === 50 || num === 51 || num === 52 || num === 53 || num === 54 || num === 55) return 'tug_special';
|
||||
if (num >= 60 && num <= 69) return 'passenger';
|
||||
if (num >= 70 && num <= 79) return 'cargo';
|
||||
if (num >= 80 && num <= 89) return 'tanker';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMarineTrafficCategory(typecode?: string, category?: string): string {
|
||||
if (!typecode) {
|
||||
// Fallback to our internal category
|
||||
if (category === 'tanker') return 'tanker';
|
||||
if (category === 'cargo') return 'cargo';
|
||||
if (category === 'fishing') return 'fishing';
|
||||
if (category === 'destroyer' || category === 'warship' || category === 'carrier' || category === 'patrol') return 'military';
|
||||
if (category === 'civilian') return 'unspecified';
|
||||
return 'unspecified';
|
||||
}
|
||||
const code = typecode.toUpperCase();
|
||||
|
||||
// AIS Ship Type number (e.g. "30" = fishing)
|
||||
const aisResult = classifyAisShipType(code);
|
||||
if (aisResult) return aisResult;
|
||||
|
||||
// Our custom typecodes (exact match)
|
||||
if (code === 'VLCC' || code === 'LNG' || code === 'LPG') return 'tanker';
|
||||
if (code === 'CONT' || code === 'BULK') return 'cargo';
|
||||
if (code === 'DDH' || code === 'DDG' || code === 'CVN' || code === 'FFG' || code === 'LCS' || code === 'MCM' || code === 'PC' || code === 'LPH') return 'military';
|
||||
if (code === 'PASS') return 'passenger';
|
||||
if (code === 'FISH' || code === 'TRAWL') return 'fishing';
|
||||
if (code === 'FISHINGGEAR') return 'fishing_gear';
|
||||
|
||||
// VesselType strings (e.g. "Cargo", "Tanker", "Passenger") — match BEFORE STAT5CODE
|
||||
// to avoid "Cargo" matching STAT5CODE prefix "C" → fishing
|
||||
@ -22,7 +50,7 @@ export function getMarineTrafficCategory(typecode?: string, category?: string):
|
||||
if (lower.includes('tanker')) return 'tanker';
|
||||
if (lower.includes('cargo') || lower.includes('container') || lower.includes('bulk')) return 'cargo';
|
||||
if (lower.includes('passenger') || lower.includes('cruise') || lower.includes('ferry')) return 'passenger';
|
||||
if (lower.includes('fishing')) return 'fishing';
|
||||
if (lower.includes('fishing') || lower.includes('trawl') || lower.includes('trawler')) return 'fishing';
|
||||
if (lower.includes('tug') || lower.includes('supply') || lower.includes('offshore')) return 'tug_special';
|
||||
if (lower.includes('high speed')) return 'high_speed';
|
||||
if (lower.includes('pleasure') || lower.includes('yacht') || lower.includes('sailing')) return 'pleasure';
|
||||
|
||||
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user