feat: 중국어선 조업분석, 어구/어망 분류, 이란 시설, 레이어 재구성

- 어선 분류 개선: 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) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-19 16:46:27 +09:00
부모 e9ce6ecdd2
커밋 7174dfd629
22개의 변경된 파일1578개의 추가작업 그리고 140개의 파일을 삭제

파일 보기

@ -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<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시간마다 전체 데이터 강제 리프레시
@ -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}
/>
</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());
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);
@ -634,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);
@ -686,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">{(() => {
@ -703,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');
@ -738,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,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<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: '🇨🇳 중국',
@ -87,8 +104,15 @@ interface ExtraLayer {
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;
@ -105,6 +129,9 @@ interface LayerPanelProps {
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({
@ -123,9 +150,12 @@ export function LayerPanel({
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) => {
@ -152,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"
@ -246,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}
@ -328,26 +326,150 @@ export function LayerPanel({
</>
)}
{/* Satellites (simple toggle) */}
{/* ═══ 항공망 그룹 ═══ */}
<LayerTreeItem
layerKey="satellites"
label={`${t('layers.satellites')} (${satelliteCount})`}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
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})`}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
/>
</div>
)}
{/* Extra layers (tab-specific) */}
{extraLayers && extraLayers.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
{/* 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}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
color={el.color}
active={layers[el.key] ?? false}
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" />

파일 보기

@ -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>
);
}

파일 보기

@ -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>
);
}

파일 보기

@ -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 <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)' }}>
<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',
}}>
{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,
}}>
<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>

파일 보기

@ -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 && <GovBuildingLayer />}
{layers.nkLaunch && <NKLaunchLayer />}
{layers.nkMissile && <NKMissileEventLayer ships={ships} />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} />}
{layers.airports && <KoreaAirportLayer />}
{layers.coastGuard && <CoastGuardLayer />}
{layers.navWarning && <NavWarningLayer />}

파일 보기

@ -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'];
@ -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<string, string> = {
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
</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;
@ -727,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">

파일 보기

@ -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 핵심항, 중국 해군 잠재 기지' },
];

파일 보기

@ -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',

파일 보기

@ -28,6 +28,7 @@ interface UseKoreaDataResult {
koreaChineseShips: Ship[];
shipsByCategory: Record<string, number>;
shipsByNationality: Record<string, number>;
fishingByNationality: Record<string, number>;
aircraftByCategory: Record<string, number>;
militaryCount: number;
}
@ -186,6 +187,17 @@ export function useKoreaData({
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> = {};
@ -211,6 +223,7 @@ export function useKoreaData({
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",

파일 보기

@ -26,6 +26,7 @@
"tanker": "유조선",
"passenger": "여객선",
"fishing": "어선",
"fishing_gear": "어구/어망",
"military": "군함",
"tug_special": "예인선",
"high_speed": "고속선",

파일 보기

@ -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,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);

파일 보기

@ -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;
}

파일 보기

@ -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~10kn3kn ,
* (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,
};
}

파일 보기

@ -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';