From 7174dfd62955f3046e448cae56796fc6de77dfed Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Thu, 19 Mar 2026 16:46:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A4=91=EA=B5=AD=EC=96=B4=EC=84=A0=20?= =?UTF-8?q?=EC=A1=B0=EC=97=85=EB=B6=84=EC=84=9D,=20=EC=96=B4=EA=B5=AC/?= =?UTF-8?q?=EC=96=B4=EB=A7=9D=20=EB=B6=84=EB=A5=98,=20=EC=9D=B4=EB=9E=80?= =?UTF-8?q?=20=EC=8B=9C=EC=84=A4,=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어선 분류 개선: AIS Ship Type 30 + category fallback + 선박명 패턴 - 어구/어망 카테고리 신설: 선박명_숫자_ / 선박명% 패턴으로 분류 - 중국어선 조업분석: GC-KCG-2026-001 + CSSA 보고서 기반 (안강망 추가) - 중국어선 선단 탐지: 본선-부속선 쌍, 운반선 환적, 선망 선단 - 어구/어망 → 모선 연결선 시각화 - 어구 SVG 아이콘 5종 (트롤/자망/안강망/선망/기본) - 이란 주변국 시설 레이어 (MEFacilityLayer 35개소) - 사우스파르스 가스전 피격 + 카타르 라스라판 보복 공격 반영 - 한국 해군부대 10개소 추가 - 레이어 재구성: 선박(최상위) → 항공망(항공기+위성) → 해양안전 → 국가기관망 - 어선 국적별 하위 분류 (선박 분류 내 어선 펼치기) - 오른쪽 패널 접기/펼치기 (한국현황, 중국현황, 조업분석, OSINT) - 항공망 기본 접힘 처리 - 센서차트 기본 숨김 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 51 ++- frontend/src/components/common/EventLog.tsx | 92 ++++- frontend/src/components/common/LayerPanel.tsx | 316 ++++++++++++------ .../src/components/icons/FishingNetIcon.tsx | 121 +++++++ .../src/components/iran/MEFacilityLayer.tsx | 80 +++++ frontend/src/components/iran/ReplayMap.tsx | 2 + frontend/src/components/iran/SatelliteMap.tsx | 2 + .../korea/ChineseFishingOverlay.tsx | 266 +++++++++++++++ .../src/components/korea/CoastGuardLayer.tsx | 51 ++- frontend/src/components/korea/KoreaMap.tsx | 2 + frontend/src/components/layers/ShipLayer.tsx | 108 +++++- frontend/src/data/middleEastFacilities.ts | 79 +++++ frontend/src/data/oilFacilities.ts | 20 +- frontend/src/hooks/useKoreaData.ts | 13 + frontend/src/i18n/locales/en/ships.json | 1 + frontend/src/i18n/locales/ko/ships.json | 1 + frontend/src/services/coastGuard.ts | 15 +- frontend/src/services/ships.ts | 67 +++- frontend/src/types.ts | 3 +- frontend/src/utils/fishingAnalysis.ts | 241 +++++++++++++ frontend/src/utils/fleetDetection.ts | 157 +++++++++ frontend/src/utils/marineTraffic.ts | 30 +- 22 files changed, 1578 insertions(+), 140 deletions(-) create mode 100644 frontend/src/components/icons/FishingNetIcon.tsx create mode 100644 frontend/src/components/iran/MEFacilityLayer.tsx create mode 100644 frontend/src/components/korea/ChineseFishingOverlay.tsx create mode 100644 frontend/src/data/middleEastFacilities.ts create mode 100644 frontend/src/utils/fishingAnalysis.ts create mode 100644 frontend/src/utils/fleetDetection.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 986682b..4ce066d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, }); @@ -85,6 +86,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { govBuildings: true, nkLaunch: true, nkMissile: true, + cnFishing: false, militaryOnly: false, }); @@ -122,6 +124,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }); }, []); + // Fishing vessel nationality filter state + const [hiddenFishingNats, setHiddenFishingNats] = useState>(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(null); // 1시간마다 전체 데이터 강제 리프레시 @@ -430,6 +442,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} @@ -549,21 +562,24 @@ 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: 59 }, - { key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: 46 }, - { key: 'navWarning', label: t('layers.navWarning'), color: '#eab308' }, - { key: 'osint', label: t('layers.osint'), color: '#ef4444' }, - { key: 'eez', label: t('layers.eez'), color: '#3b82f6' }, - { key: 'piracy', label: t('layers.piracy'), color: '#ef4444' }, - { key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: 8 }, - { key: 'ports', label: '항구', color: '#3b82f6', count: 46 }, - { key: 'militaryBases', label: '군사시설', color: '#ef4444', count: 38 }, - { key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: 32 }, - { key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19 }, - { key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4 }, + // 해양안전 + { 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} @@ -572,6 +588,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { shipsByNationality={koreaData.shipsByNationality} hiddenNationalities={hiddenNationalities} onNationalityToggle={toggleNationality} + fishingByNationality={koreaData.fishingByNationality} + hiddenFishingNats={hiddenFishingNats} + onFishingNatToggle={toggleFishingNat} /> diff --git a/frontend/src/components/common/EventLog.tsx b/frontend/src/components/common/EventLog.tsx index 996430e..8294c08 100644 --- a/frontend/src/components/common/EventLog.tsx +++ b/frontend/src/components/common/EventLog.tsx @@ -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>(new Set()); + 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 && ( <> -
+
toggleCollapse('osint-live')}> + {collapsed.has('osint-live') ? '▶' : '▼'} {t('events:osint.liveTitle')} {osintFeed.length}
+ {!collapsed.has('osint-live') && (
{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, ); })}
+ )} )} {isLive && osintFeed.length === 0 && ( @@ -579,7 +592,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {/* 한국 선박 현황 — 선종별 분류 */}
-
+
toggleCollapse('kr-ships')}> + {collapsed.has('kr-ships') ? '▶' : '▼'} {'\u{1F1F0}\u{1F1F7}'} {t('ships:shipStatus.koreanTitle')} {koreanShips.length}{t('common:units.vessels')} @@ -594,7 +608,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, )}
- {koreanShips.length > 0 && (() => { + {!collapsed.has('kr-ships') && koreanShips.length > 0 && (() => { const groups: Record = {}; for (const s of koreanShips) { const cat = getMarineTrafficCategory(s.typecode, s.category); @@ -634,12 +648,13 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, {/* 중국 선박 현황 */}
-
+
toggleCollapse('cn-ships')}> + {collapsed.has('cn-ships') ? '▶' : '▼'} {'\u{1F1E8}\u{1F1F3}'} {t('ships:shipStatus.chineseTitle')} {chineseShips.length}{t('common:units.vessels')}
- {chineseShips.length > 0 && (() => { + {!collapsed.has('cn-ships') && chineseShips.length > 0 && (() => { const groups: Record = {}; for (const s of chineseShips) { const cat = getMarineTrafficCategory(s.typecode, s.category); @@ -686,10 +701,71 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, })()}
+ {/* 중국 어선 조업 분석 (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 ( +
+
toggleCollapse('cn-fishing')}> + {collapsed.has('cn-fishing') ? '▶' : '▼'} + 🎣 + 중국어선 조업분석 ({stats.total}척) + + {stats.operating}척 조업중 + +
+ {/* 위험 경보 */} + {!collapsed.has('cn-fishing') && (stats.critical > 0 || stats.high > 0) && ( +
+ 🚨 + + {stats.critical > 0 && `CRITICAL ${stats.critical}척`} + {stats.critical > 0 && stats.high > 0 && ' · '} + {stats.high > 0 && `HIGH ${stats.high}척`} + +
+ )} + {/* 조업/비조업 요약 + 어구별 분류 */} + {!collapsed.has('cn-fishing') && ( + <> +
+ 🔴 조업 {stats.operating} + 🟢 비조업 {stats.idle} + 총 {stats.total}척 +
+
+ {gearOrder.map(gear => { + const count = stats.byGear[gear]; + if (!count) return null; + const info = GEAR_LABELS[gear]; + return ( +
+ {info.icon} + {info.ko} + + {count} + +
+ ); + })} +
+ + )} +
+ ); + })()} + {/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */} {osintFeed.length > 0 && ( <> -
+
toggleCollapse('osint-korea')}> + {collapsed.has('osint-korea') ? '▶' : '▼'} {'\u{1F1F0}\u{1F1F7}'} {t('events:osint.koreaLiveTitle')} {(() => { @@ -703,6 +779,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, }).length; })()}
+ {!collapsed.has('osint-korea') && (
{(() => { const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil'); @@ -738,6 +815,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, ); })}
+ )} )} {osintFeed.length === 0 && ( diff --git a/frontend/src/components/common/LayerPanel.tsx b/frontend/src/components/common/LayerPanel.tsx index a572f76..1d1ecdc 100644 --- a/frontend/src/components/common/LayerPanel.tsx +++ b/frontend/src/components/common/LayerPanel.tsx @@ -40,6 +40,7 @@ const MT_CAT_COLORS: Record = { 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,10 +65,25 @@ 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 = { + CN: '🇨🇳 중국어선', + KR: '🇰🇷 한국어선', + JP: '🇯🇵 일본어선', + other: '🏳️ 기타어선', +}; +const FISHING_NAT_COLORS: Record = { + CN: '#ef4444', + KR: '#3b82f6', + JP: '#f472b6', + other: '#6b7280', +}; const NAT_LABELS: Record = { KR: '🇰🇷 한국', CN: '🇨🇳 중국', @@ -87,8 +104,15 @@ interface ExtraLayer { label: string; color: string; count?: number; + group?: string; } +const GROUP_META: Record = { + '항공망': { label: '항공망', color: '#22d3ee' }, + '국가기관망': { label: '국가기관망', color: '#f59e0b' }, + '해양안전': { label: '해양안전', color: '#3b82f6' }, +}; + interface LayerPanelProps { layers: Record; onToggle: (key: string) => void; @@ -105,6 +129,9 @@ interface LayerPanelProps { shipsByNationality?: Record; hiddenNationalities?: Set; onNationalityToggle?: (nat: string) => void; + fishingByNationality?: Record; + hiddenFishingNats?: Set; + onFishingNatToggle?: (nat: string) => void; } export function LayerPanel({ @@ -123,9 +150,12 @@ export function LayerPanel({ shipsByNationality, hiddenNationalities, onNationalityToggle, + fishingByNationality, + hiddenFishingNats, + onFishingNatToggle, }: LayerPanelProps) { const { t } = useTranslation(['common', 'ships']); - const [expanded, setExpanded] = useState>(new Set(['aircraft', 'ships'])); + const [expanded, setExpanded] = useState>(new Set(['ships'])); const [legendOpen, setLegendOpen] = useState>(new Set()); const toggleExpand = useCallback((key: string) => { @@ -152,84 +182,7 @@ export function LayerPanel({

LAYERS

- {/* Aircraft tree */} - onToggle('aircraft')} - onExpand={() => toggleExpand('aircraft')} - /> - {layers.aircraft && expanded.has('aircraft') && ( -
- {AC_CATEGORIES.map(cat => { - const count = aircraftByCategory[cat] || 0; - if (count === 0) return null; - return ( -
- )} - + {/* ═══ 선박 (최상위) ═══ */} {/* Ships tree */} { const count = shipsByMtCategory[cat] || 0; if (count === 0) return null; + + // 어선은 국적별 하위 분류 표시 + if (cat === 'fishing' && fishingByNationality && hiddenFishingNats && onFishingNatToggle) { + const isFishingExpanded = expanded.has('fishing-sub'); + return ( +
+
+ { e.stopPropagation(); toggleExpand('fishing-sub'); }} + > + {isFishingExpanded ? '▼' : '▶'} + +
+
+
+ {isFishingExpanded && !hiddenShipCategories.has('fishing') && ( +
+ {FISHING_NAT_CATEGORIES.map(nat => { + const fCount = fishingByNationality[nat] || 0; + if (fCount === 0) return null; + return ( +
+ )} +
+ ); + } + return ( )} - {/* Satellites (simple toggle) */} + {/* ═══ 항공망 그룹 ═══ */} onToggle('satellites')} + layerKey="group-항공망" + label="항공망" + color="#22d3ee" + active + expandable + isExpanded={expanded.has('group-항공망')} + onToggle={() => toggleExpand('group-항공망')} + onExpand={() => toggleExpand('group-항공망')} /> + {expanded.has('group-항공망') && ( +
+ {/* Aircraft tree */} + onToggle('aircraft')} + onExpand={() => toggleExpand('aircraft')} + /> + {layers.aircraft && expanded.has('aircraft') && ( +
+ {AC_CATEGORIES.map(cat => { + const count = aircraftByCategory[cat] || 0; + if (count === 0) return null; + return ( +
+ )} + {/* Satellites */} + onToggle('satellites')} + /> +
+ )} - {/* Extra layers (tab-specific) */} - {extraLayers && extraLayers.map(el => ( - onToggle(el.key)} - /> - ))} + {/* Extra layers — grouped */} + {extraLayers && (() => { + const grouped: Record = {}; + 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 ( +
+ toggleExpand(`group-${groupName}`)} + onExpand={() => toggleExpand(`group-${groupName}`)} + /> + {isGroupExpanded && ( +
+ {items.map(el => ( + onToggle(el.key)} + /> + ))} +
+ )} +
+ ); + })} + {/* Ungrouped layers */} + {ungrouped.map(el => ( + onToggle(el.key)} + /> + ))} + + ); + })()}
diff --git a/frontend/src/components/icons/FishingNetIcon.tsx b/frontend/src/components/icons/FishingNetIcon.tsx new file mode 100644 index 0000000..8642d67 --- /dev/null +++ b/frontend/src/components/icons/FishingNetIcon.tsx @@ -0,0 +1,121 @@ +// ═══ 어구/어망 아이콘 — 그물망 형태 SVG ═══ + +interface Props { + color?: string; + size?: number; +} + +/** 기본 어망 아이콘 (반달형 그물 + 부표) */ +export function FishingNetIcon({ color = '#f97316', size = 16 }: Props) { + return ( + + {/* 그물망 곡선 (반달형) */} + + {/* 가로 그물선 */} + + + {/* 세로 그물선 */} + + + + {/* 대각선 그물 */} + + + + + {/* 부표 (상단 구슬) */} + + + + + + + ); +} + +/** 트롤(저인망) 아이콘 — 자루형 그물 */ +export function TrawlNetIcon({ color = '#ef4444', size = 16 }: Props) { + return ( + + {/* 자루형 망 */} + + {/* 그물 패턴 */} + + + + + + {/* 전개판 */} + + + + ); +} + +/** 자망(유자망) 아이콘 — 수직 그물벽 */ +export function GillnetIcon({ color = '#f97316', size = 16 }: Props) { + return ( + + {/* 수직 그물 */} + + + + + + + {/* 가로 연결 */} + + + + {/* 상단 부표 */} + + + + + {/* 하단 침자 */} + + + + + + ); +} + +/** 안강망(Stow net) 아이콘 — 조류 방향 자루형 */ +export function StowNetIcon({ color = '#eab308', size = 16 }: Props) { + return ( + + {/* 프레임 */} + + {/* 자루 */} + + {/* 그물 패턴 */} + + + + + {/* 조류 화살표 */} + + + ); +} + +/** 선망(위망) 아이콘 — 원형 포위 그물 */ +export function PurseSeineIcon({ color = '#3b82f6', size = 16 }: Props) { + return ( + + {/* 원형 그물 */} + + {/* 그물 패턴 */} + + + + {/* 죔줄 */} + + {/* 부표 */} + + + + + ); +} diff --git a/frontend/src/components/iran/MEFacilityLayer.tsx b/frontend/src/components/iran/MEFacilityLayer.tsx new file mode 100644 index 0000000..b6ca041 --- /dev/null +++ b/frontend/src/components/iran/MEFacilityLayer.tsx @@ -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(null); + + return ( + <> + {ME_FACILITIES.map(f => { + const meta = ME_FACILITY_TYPE_META[f.type]; + return ( + { e.originalEvent.stopPropagation(); setSelected(f); }}> +
+
+ {meta.icon} +
+
+ {f.nameKo.length > 12 ? f.nameKo.slice(0, 12) + '..' : f.nameKo} +
+
+
+ ); + })} + + {selected && (() => { + const meta = ME_FACILITY_TYPE_META[selected.type]; + return ( + setSelected(null)} closeOnClick={false} + anchor="bottom" maxWidth="320px" className="gl-popup"> +
+
+ {selected.flag} + {meta.icon} {selected.nameKo} +
+
+ + {meta.label} + + + {selected.country} + +
+
+ {selected.description} +
+
+ {selected.name} +
+
+ {selected.lat.toFixed(4)}°{selected.lat >= 0 ? 'N' : 'S'}, {selected.lng.toFixed(4)}°E +
+
+
+ ); + })()} + + ); +}); diff --git a/frontend/src/components/iran/ReplayMap.tsx b/frontend/src/components/iran/ReplayMap.tsx index 3bf4a99..cdf25c1 100644 --- a/frontend/src/components/iran/ReplayMap.tsx +++ b/frontend/src/components/iran/ReplayMap.tsx @@ -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 && } {layers.airports && } {layers.oilFacilities && } + {layers.meFacilities && } ); } diff --git a/frontend/src/components/iran/SatelliteMap.tsx b/frontend/src/components/iran/SatelliteMap.tsx index 67d854f..eb2bc9a 100644 --- a/frontend/src/components/iran/SatelliteMap.tsx +++ b/frontend/src/components/iran/SatelliteMap.tsx @@ -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 && } {layers.oilFacilities && } {layers.airports && } + {layers.meFacilities && } ); } diff --git a/frontend/src/components/korea/ChineseFishingOverlay.tsx b/frontend/src/components/korea/ChineseFishingOverlay.tsx new file mode 100644 index 0000000..7f000ff --- /dev/null +++ b/frontend/src/components/korea/ChineseFishingOverlay.tsx @@ -0,0 +1,266 @@ +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 ; + case 'gillnet': + return ; + case 'stow_net': + return ; + case 'purse_seine': + return ; + default: + return ; + } +} + +/** 선박 역할 추정 — 속도/크기/카테고리 기반 */ +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(); + 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 && ( + + + + )} + + {/* 어구/어망 위치 마커 (모선 연결된 것) */} + {gearLinks.map(link => ( + +
+ +
+
+ ← {link.parentName} +
+
+ ))} + + {/* 조업 중 어선 — 어구 아이콘 */} + {operating.map(({ ship, analysis }) => { + const meta = GEAR_LABELS[analysis.gearType]; + return ( + +
+ +
+
+ ); + })} + + {/* 본선/부속선/어선 역할 라벨 */} + {analyzed.filter(a => a.role.role).map(({ ship, role }) => ( + +
+ {role.roleKo} +
+
+ ))} + + {/* 운반선 라벨 */} + {carriers.map(s => ( + +
+ 운반 +
+
+ ))} + + ); +} diff --git a/frontend/src/components/korea/CoastGuardLayer.tsx b/frontend/src/components/korea/CoastGuardLayer.tsx index df10aef..623579c 100644 --- a/frontend/src/components/korea/CoastGuardLayer.tsx +++ b/frontend/src/components/korea/CoastGuardLayer.tsx @@ -10,6 +10,7 @@ const TYPE_COLOR: Record = { station: '#4dabf7', substation: '#69db7c', vts: '#da77f2', + navy: '#3b82f6', }; const TYPE_SIZE: Record = { @@ -18,6 +19,7 @@ const TYPE_SIZE: Record = { 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 ( + + + + + + + ); + } + if (isVts) { return ( @@ -76,6 +89,14 @@ export function CoastGuardLayer() { {f.name.replace('해양경찰청', '').replace('지방', '').trim() || '본청'}
)} + {f.type === 'navy' && ( +
+ {f.name.replace('해군', '').replace('사령부', '사').replace('전대', '전').replace('전단', '전').trim().slice(0, 8)} +
+ )} {f.type === 'vts' && (
setSelected(null)} closeOnClick={false} anchor="bottom" maxWidth="280px" className="gl-popup"> -
-
+
- {selected.name} + color: selected.type === 'vts' ? '#fff' : '#000', + gap: 6, padding: '6px 10px', + }}> + {selected.type === 'navy' ? ( + + ) : selected.type === 'vts' ? ( + 📡 + ) : ( + 🚔 + )} + {selected.name}
-
+
+ color: selected.type === 'vts' ? '#fff' : '#000', + padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700, + }}> {CG_TYPE_LABEL[selected.type]} - + {t('coastGuard.agency')}
-
+
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index aaea183..807eb55 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -20,6 +20,7 @@ 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'; @@ -267,6 +268,7 @@ export function KoreaMap({ ships, aircraft, satellites, layers, osintFeed, curre {layers.govBuildings && } {layers.nkLaunch && } {layers.nkMissile && } + {layers.cnFishing && } {layers.airports && } {layers.coastGuard && } {layers.navWarning && } diff --git a/frontend/src/components/layers/ShipLayer.tsx b/frontend/src/components/layers/ShipLayer.tsx index 4f5d71d..c6e7e4c 100644 --- a/frontend/src/components/layers/ShipLayer.tsx +++ b/frontend/src/components/layers/ShipLayer.tsx @@ -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 = { 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 = { tanker: '#e74c3c', passenger: '#4caf50', fishing: '#42a5f5', + fishing_gear: '#f97316', pleasure: '#e91e8c', military: '#d32f2f', tug_special: '#2e7d32', @@ -93,7 +97,7 @@ const FLAG_EMOJI: Record = { // icon-size multiplier (symbol layer, base=64px) const SIZE_MAP: Record = { 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']; @@ -400,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, @@ -461,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 = { + pair: '#ef4444', + carrier: '#f97316', + lighting: '#eab308', + mothership: '#dc2626', + subsidiary: '#6b7280', + }; + if (!imageReady) return null; return ( @@ -555,15 +598,53 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM ))} + {/* Fleet connection lines — 중국어선 클릭 시만 */} + {fleet && fleetLineGeoJson.features.length > 0 && selectedShip?.flag === 'CN' && ( + + + + )} + + {/* Fleet member markers — 중국어선 클릭 시만 */} + {fleet && selectedShip?.flag === 'CN' && fleet.members.map(m => ( + +
+ {m.role === 'pair' ? 'PT' : m.role === 'carrier' ? 'FC' : m.role === 'lighting' ? '灯' : '●'} +
+
+ {m.roleKo} {m.distanceNm.toFixed(1)}NM +
+
+ ))} + {/* Popup for selected ship */} {selectedShip && ( - setSelectedMmsi(null)} /> + 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; @@ -727,6 +808,25 @@ const ShipPopup = memo(function ShipPopup({ ship, onClose }: { ship: Ship; onClo
)} + {/* Fleet info (중국어선만) */} + {fleet && fleet.members.length > 0 && ( +
+
+ 🔗 {fleet.fleetTypeKo} — {fleet.members.length}척 연결 +
+ {fleet.members.slice(0, 5).map(m => ( +
+ {m.roleKo} + {m.ship.name || m.ship.mmsi} + {m.distanceNm.toFixed(1)}NM +
+ ))} + {fleet.members.length > 5 && ( +
...외 {fleet.members.length - 5}척
+ )} +
+ )} + {/* Footer */}
diff --git a/frontend/src/data/middleEastFacilities.ts b/frontend/src/data/middleEastFacilities.ts new file mode 100644 index 0000000..6bf9e61 --- /dev/null +++ b/frontend/src/data/middleEastFacilities.ts @@ -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 = { + 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 핵심항, 중국 해군 잠재 기지' }, +]; diff --git a/frontend/src/data/oilFacilities.ts b/frontend/src/data/oilFacilities.ts index e181239..8760592 100644 --- a/frontend/src/data/oilFacilities.ts +++ b/frontend/src/data/oilFacilities.ts @@ -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', diff --git a/frontend/src/hooks/useKoreaData.ts b/frontend/src/hooks/useKoreaData.ts index 7d09811..99ba14a 100644 --- a/frontend/src/hooks/useKoreaData.ts +++ b/frontend/src/hooks/useKoreaData.ts @@ -28,6 +28,7 @@ interface UseKoreaDataResult { koreaChineseShips: Ship[]; shipsByCategory: Record; shipsByNationality: Record; + fishingByNationality: Record; aircraftByCategory: Record; militaryCount: number; } @@ -186,6 +187,17 @@ export function useKoreaData({ return counts; }, [ships]); + const fishingByNationality = useMemo(() => { + const counts: Record = {}; + 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 = {}; @@ -211,6 +223,7 @@ export function useKoreaData({ koreaChineseShips, shipsByCategory, shipsByNationality, + fishingByNationality, aircraftByCategory, militaryCount, }; diff --git a/frontend/src/i18n/locales/en/ships.json b/frontend/src/i18n/locales/en/ships.json index 8b10eac..9dff65a 100644 --- a/frontend/src/i18n/locales/en/ships.json +++ b/frontend/src/i18n/locales/en/ships.json @@ -26,6 +26,7 @@ "tanker": "Tanker", "passenger": "Passenger", "fishing": "Fishing", + "fishing_gear": "Gear/Net", "military": "Military", "tug_special": "Tug/Special", "high_speed": "High Speed", diff --git a/frontend/src/i18n/locales/ko/ships.json b/frontend/src/i18n/locales/ko/ships.json index 48e4481..50125f6 100644 --- a/frontend/src/i18n/locales/ko/ships.json +++ b/frontend/src/i18n/locales/ko/ships.json @@ -26,6 +26,7 @@ "tanker": "유조선", "passenger": "여객선", "fishing": "어선", + "fishing_gear": "어구/어망", "military": "군함", "tug_special": "예인선", "high_speed": "고속선", diff --git a/frontend/src/services/coastGuard.ts b/frontend/src/services/coastGuard.ts index 50a318f..5e5b2e6 100644 --- a/frontend/src/services/coastGuard.ts +++ b/frontend/src/services/coastGuard.ts @@ -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 = { 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 }, ]; diff --git a/frontend/src/services/ships.ts b/frontend/src/services/ships.ts index 1a14997..16983a8 100644 --- a/frontend/src/services/ships.ts +++ b/frontend/src/services/ships.ts @@ -83,7 +83,7 @@ const SPG_VESSEL_TYPE_MAP: Record = { '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,65 @@ 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') { + if (!(window as any).__kindCodeMap) (window as any).__kindCodeMap = {}; + const km = (window as any).__kindCodeMap; + const key = `${kindCode}|${rawShipTy}`; + km[key] = (km[key] || 0) + 1; + if (!(window as any).__kindCodeLogged && Object.keys(km).length > 20) { + (window as any).__kindCodeLogged = true; + console.log('[SHIP DEBUG] kindCode|shipTy distribution:', JSON.stringify( + Object.entries(km).sort((a: any, b: any) => (b[1] as number) - (a[1] as number)).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); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1bad617..458e7fe 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } diff --git a/frontend/src/utils/fishingAnalysis.ts b/frontend/src/utils/fishingAnalysis.ts new file mode 100644 index 0000000..66c79ff --- /dev/null +++ b/frontend/src/utils/fishingAnalysis.ts @@ -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 = { + 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 = { + 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 = { + 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, + }; +} diff --git a/frontend/src/utils/fleetDetection.ts b/frontend/src/utils/fleetDetection.ts new file mode 100644 index 0000000..a84cbab --- /dev/null +++ b/frontend/src/utils/fleetDetection.ts @@ -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, + }; +} diff --git a/frontend/src/utils/marineTraffic.ts b/frontend/src/utils/marineTraffic.ts index e10b374..18fc0e5 100644 --- a/frontend/src/utils/marineTraffic.ts +++ b/frontend/src/utils/marineTraffic.ts @@ -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';