Merge pull request 'release: 2026-03-23 (4건 커밋)' (#151) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m58s

This commit is contained in:
htlee 2026-03-23 08:25:33 +09:00
커밋 f36e1b297b
22개의 변경된 파일3465개의 추가작업 그리고 216개의 파일을 삭제

파일 보기

@ -4,7 +4,26 @@
## [Unreleased]
## [2026-03-20.3]
## [2026-03-23]
### 추가
- 해외시설 레이어: 위험시설(원전/화학/연료저장) + 중국·일본 발전소/군사시설
- 현장분석 Python 연동 + FieldAnalysisModal (어구/선단 분석 대시보드)
- 선단 선택 시 소속 선박 deck.gl 강조 (어구 그룹과 동일 패턴)
- 전 시설 kind에 리치 Popup 디자인 통합 (헤더·배지·상세정보)
- LAYERS 패널 카운트 통일 — 하드코딩→실제 데이터 기반 동적 표기
### 변경
- DOM Marker → deck.gl 전환 (폴리곤 인터랙션 포함)
- 줌 레벨별 아이콘/텍스트 스케일 연동 (z4=0.8x ~ z14=4.2x)
### 수정
- 불법어선 탭 복원 + ShipLayer feature-state 필터 에러 수정
- 해외시설 토글을 militaryOnly에서 분리 (선박/항공기 필터 간섭 해소)
- deck.gl 레이어 호버 시 pointer 커서 표시
- prediction 증분 수집 버그 수정 (vessel_store.py)
## [2026-03-20]
### 변경
- deck.gl 전면 전환: DOM Marker → GPU 렌더링 (WebGL)
@ -12,22 +31,14 @@
### 추가
- NK 미사일 궤적선 + 정적 마커 Popup + 어구 강조
### 수정
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
## [2026-03-20.2]
### 추가
- Python 분석 결과 오버레이: 위험도 마커(CRITICAL/HIGH/MEDIUM) + 다크베셀/GPS 스푸핑 경고
- Python 분석 결과 오버레이: 위험도 마커 + 다크베셀/GPS 스푸핑 경고
- AI 분석 통계 패널 (우상단, 접이식): 분석 대상/위험/다크/선단 집계
- 불법어선/다크베셀/중국어선감시 Python 분석 연동
- Backend vessel-analysis REST API + DB 테이블 복원
- 특정어업수역 ~Ⅳ 실제 폴리곤 기반 수역 분류
## [2026-03-20]
### 추가
- 특정어업수역 ~Ⅳ 실제 폴리곤 기반 수역 분류 (경도 하드코딩 → point-in-polygon)
### 수정
- 해저케이블 날짜변경선 좌표 보정 + 렌더링 성능 개선
## [2026-03-19]

파일 보기

@ -1881,6 +1881,11 @@
border-top-color: rgba(10, 10, 26, 0.96) !important;
}
/* 중국어선 오버레이 마커 — 이벤트 차단 */
.maplibregl-marker:has(.cn-fishing-no-events) {
pointer-events: none;
}
.gl-popup .maplibregl-popup-close-button,
.event-popup .maplibregl-popup-close-button {
color: #aaa !important;

파일 보기

@ -22,6 +22,22 @@ import { useAuth } from './hooks/useAuth';
import { useTranslation } from 'react-i18next';
import LoginPage from './components/auth/LoginPage';
import CollectorMonitor from './components/common/CollectorMonitor';
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
import { EAST_ASIA_PORTS } from './data/ports';
import { KOREAN_AIRPORTS } from './services/airports';
import { MILITARY_BASES } from './data/militaryBases';
import { GOV_BUILDINGS } from './data/govBuildings';
import { KOREA_WIND_FARMS } from './data/windFarms';
import { NK_LAUNCH_SITES } from './data/nkLaunchSites';
import { NK_MISSILE_EVENTS } from './data/nkMissileEvents';
import { COAST_GUARD_FACILITIES } from './services/coastGuard';
import { NAV_WARNINGS } from './services/navWarning';
import { PIRACY_ZONES } from './services/piracy';
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
import { HAZARD_FACILITIES } from './data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from './data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from './data/jpFacilities';
import './App.css';
function App() {
@ -65,6 +81,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
oilFacilities: true,
meFacilities: true,
militaryOnly: false,
overseasUS: false,
overseasUK: false,
overseasIran: false,
overseasUAE: false,
overseasSaudi: false,
overseasOman: false,
overseasQatar: false,
overseasKuwait: false,
overseasIraq: false,
overseasBahrain: false,
});
// Korea tab layer visibility (lifted from KoreaMap)
@ -89,6 +115,21 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
nkMissile: true,
cnFishing: false,
militaryOnly: false,
overseasChina: false,
overseasJapan: false,
cnPower: false,
cnMilitary: false,
jpPower: false,
jpMilitary: false,
hazardPetrochemical: false,
hazardLng: false,
hazardOilTank: false,
hazardPort: false,
energyNuclear: false,
energyThermal: false,
industryShipyard: false,
industryWastewater: false,
industryHeavy: false,
});
const toggleKoreaLayer = useCallback((key: string) => {
@ -148,6 +189,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
}, []);
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
@ -321,6 +363,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<span className="text-[11px]">🎣</span>
</button>
<button
type="button"
className={`mode-btn ${showFieldAnalysis ? 'active' : ''}`}
onClick={() => setShowFieldAnalysis(v => !v)}
title="현장분석"
>
<span className="text-[11px]">📊</span>
</button>
</div>
)}
@ -459,6 +510,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
{ key: 'meFacilities', label: '주요시설/군사', color: '#ef4444', count: 35 },
{ key: 'sensorCharts', label: t('layers.sensorCharts'), color: '#22c55e' },
]}
overseasItems={[
{ key: 'overseasUS', label: '🇺🇸 미국', color: '#3b82f6' },
{ key: 'overseasUK', label: '🇬🇧 영국', color: '#dc2626' },
{ key: 'overseasIran', label: '🇮🇷 이란', color: '#22c55e' },
{ key: 'overseasUAE', label: '🇦🇪 UAE', color: '#f59e0b' },
{ key: 'overseasSaudi', label: '🇸🇦 사우디아라비아', color: '#84cc16' },
{ key: 'overseasOman', label: '🇴🇲 오만', color: '#e11d48' },
{ key: 'overseasQatar', label: '🇶🇦 카타르', color: '#8b5cf6' },
{ key: 'overseasKuwait', label: '🇰🇼 쿠웨이트', color: '#f97316' },
{ key: 'overseasIraq', label: '🇮🇶 이라크', color: '#65a30d' },
{ key: 'overseasBahrain', label: '🇧🇭 바레인', color: '#e11d48' },
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}
onAcCategoryToggle={toggleAcCategory}
@ -553,6 +616,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
<>
<main className="app-main">
<div className="map-panel">
{showFieldAnalysis && (
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
)}
<KoreaMap
ships={koreaFiltersResult.filteredShips}
allShips={koreaData.visibleShips}
@ -579,23 +645,51 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
satelliteCount={koreaData.satPositions.length}
extraLayers={[
// 해양안전
{ 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: 'cables', label: t('layers.cables'), color: '#00e5ff', count: KOREA_SUBMARINE_CABLES.length, group: '해양안전' },
{ key: 'coastGuard', label: t('layers.coastGuard'), color: '#4dabf7', count: COAST_GUARD_FACILITIES.length, group: '해양안전' },
{ key: 'navWarning', label: t('layers.navWarning'), color: '#eab308', count: NAV_WARNINGS.length, group: '해양안전' },
{ key: 'osint', label: t('layers.osint'), color: '#ef4444', count: koreaData.osintFeed.length, group: '해양안전' },
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', count: 1, group: '해양안전' },
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', count: PIRACY_ZONES.length, group: '해양안전' },
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: NK_MISSILE_EVENTS.length, group: '해양안전' },
{ key: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: NK_LAUNCH_SITES.length, 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: '국가기관망' },
{ key: 'ports', label: '항구', color: '#3b82f6', count: EAST_ASIA_PORTS.length, group: '국가기관망' },
{ key: 'airports', label: t('layers.airports'), color: '#a78bfa', count: KOREAN_AIRPORTS.length, group: '국가기관망' },
{ key: 'militaryBases', label: '군사시설', color: '#ef4444', count: MILITARY_BASES.length, group: '국가기관망' },
{ key: 'govBuildings', label: '정부기관', color: '#f59e0b', count: GOV_BUILDINGS.length, group: '국가기관망' },
// 에너지/발전시설
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '에너지/발전시설' },
{ key: 'windFarm', label: '풍력단지', color: '#00bcd4', count: KOREA_WIND_FARMS.length, group: '에너지/발전시설' },
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: HAZARD_FACILITIES.filter(f => f.type === 'nuclear').length, group: '에너지/발전시설' },
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: HAZARD_FACILITIES.filter(f => f.type === 'thermal').length, group: '에너지/발전시설' },
// 위험시설
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: HAZARD_FACILITIES.filter(f => f.type === 'petrochemical').length, group: '위험시설' },
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: HAZARD_FACILITIES.filter(f => f.type === 'lng').length, group: '위험시설' },
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: HAZARD_FACILITIES.filter(f => f.type === 'oilTank').length, group: '위험시설' },
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: HAZARD_FACILITIES.filter(f => f.type === 'hazardPort').length, group: '위험시설' },
// 산업공정/제조시설
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: HAZARD_FACILITIES.filter(f => f.type === 'shipyard').length, group: '산업공정/제조시설' },
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: HAZARD_FACILITIES.filter(f => f.type === 'wastewater').length, group: '산업공정/제조시설' },
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: HAZARD_FACILITIES.filter(f => f.type === 'heavyIndustry').length, group: '산업공정/제조시설' },
]}
overseasItems={[
{
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
count: CN_POWER_PLANTS.length + CN_MILITARY_FACILITIES.length,
children: [
{ key: 'cnPower', label: '발전소', color: '#a855f7', count: CN_POWER_PLANTS.length },
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444', count: CN_MILITARY_FACILITIES.length },
],
},
{
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
count: JP_POWER_PLANTS.length + JP_MILITARY_FACILITIES.length,
children: [
{ key: 'jpPower', label: '발전소', color: '#a855f7', count: JP_POWER_PLANTS.length },
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444', count: JP_MILITARY_FACILITIES.length },
],
},
]}
hiddenAcCategories={hiddenAcCategories}
hiddenShipCategories={hiddenShipCategories}

파일 보기

@ -2,6 +2,7 @@ import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { GeoEvent, Ship } from '../../types';
import type { OsintItem } from '../../services/osint';
import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../services/disasterNews';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
import type { FishingGearType } from '../../utils/fishingAnalysis';
@ -329,7 +330,7 @@ const NEWS_CATEGORY_COLORS: Record<BreakingNews['category'], string> = {
};
const EMPTY_OSINT: OsintItem[] = [];
const EMPTY_SHIPS: import('../types').Ship[] = [];
const EMPTY_SHIPS: Ship[] = [];
function useTimeAgo() {
const { t } = useTranslation('common');
@ -597,7 +598,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
<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>
{onToggleHighlightKorean && dashboardTab === 'iran' && (
{onToggleHighlightKorean && (dashboardTab as string) === 'iran' && (
<button
type="button"
className={`korean-highlight-toggle ${highlightKoreanShips ? 'active' : ''}`}
@ -761,6 +762,57 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
);
})()}
{/* 재난/안전뉴스 */}
{isLive && (() => {
const disasterItems = getDisasterNews();
return (
<>
<div className="osint-header" style={{ cursor: 'pointer' }} onClick={() => toggleCollapse('disaster-news')}>
<span style={{ fontSize: 8, color: 'var(--kcg-dim)', width: 12, textAlign: 'center' }}>{collapsed.has('disaster-news') ? '▶' : '▼'}</span>
<span style={{ fontSize: 11 }}>🚨</span>
<span className="osint-title">/</span>
<span className="osint-count">{disasterItems.length}</span>
<a
href="https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619"
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--kcg-dim)', textDecoration: 'none' }}
onClick={e => e.stopPropagation()}
>
</a>
</div>
{!collapsed.has('disaster-news') && (
<div className="osint-list" style={{ maxHeight: 310, overflowY: 'auto' }}>
{disasterItems.map(item => {
const icon = getDisasterCatIcon(item.category);
const color = getDisasterCatColor(item.category);
const isRecent = Date.now() - item.timestamp < 3600_000 * 6;
return (
<a
key={item.id}
className={`osint-item${isRecent ? ' osint-recent' : ''}`}
href={item.url}
target="_blank"
rel="noopener noreferrer"
>
<div className="osint-item-top">
<span className="osint-cat-tag" style={{ background: color }}>
{icon} {item.category === 'sea' ? '해양' : item.category === 'chemical' ? '화학' : item.category === 'fire' ? '화재' : item.category === 'earthquake' ? '지진' : item.category === 'flood' ? '홍수' : item.category === 'typhoon' ? '태풍' : item.category === 'safety' ? '안전' : '재난'}
</span>
<span className="osint-time">{timeAgo(item.timestamp)}</span>
</div>
<div className="osint-item-title">{item.title}</div>
<div className="osint-item-source">{item.source}</div>
</a>
);
})}
</div>
)}
</>
);
})()}
{/* OSINT 피드 — 한국: 일반(general) 제외, 해양 관련만 표시 (라이브/리플레이 모두) */}
{osintFeed.length > 0 && (
<>

파일 보기

@ -107,12 +107,27 @@ interface ExtraLayer {
group?: string;
}
const GROUP_META: Record<string, { label: string; color: string }> = {
'항공망': { label: '항공망', color: '#22d3ee' },
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
'해양안전': { label: '해양안전', color: '#3b82f6' },
const GROUP_META: Record<string, { label: string; color: string; superGroup?: string }> = {
'항공망': { label: '항공망', color: '#22d3ee' },
'해양안전': { label: '해양안전', color: '#3b82f6' },
'국가기관망': { label: '국가기관망', color: '#f59e0b' },
'위험시설': { label: '위험시설', color: '#ef4444', superGroup: '위험/산업 인프라' },
'에너지/발전시설': { label: '에너지/발전시설', color: '#a855f7', superGroup: '위험/산업 인프라' },
'산업공정/제조시설': { label: '산업공정/제조시설', color: '#0ea5e9', superGroup: '위험/산업 인프라' },
};
const SUPER_GROUP_META: Record<string, { label: string; color: string }> = {
'위험/산업 인프라': { label: '위험/산업 인프라', color: '#f97316' },
};
interface OverseasItem {
key: string;
label: string;
color: string;
count?: number;
children?: OverseasItem[];
}
interface LayerPanelProps {
layers: Record<string, boolean>;
onToggle: (key: string) => void;
@ -122,6 +137,7 @@ interface LayerPanelProps {
shipTotal: number;
satelliteCount: number;
extraLayers?: ExtraLayer[];
overseasItems?: OverseasItem[];
hiddenAcCategories: Set<string>;
hiddenShipCategories: Set<string>;
onAcCategoryToggle: (cat: string) => void;
@ -143,6 +159,7 @@ export function LayerPanel({
shipTotal,
satelliteCount,
extraLayers,
overseasItems,
hiddenAcCategories,
hiddenShipCategories,
onAcCategoryToggle,
@ -174,9 +191,10 @@ export function LayerPanel({
});
}, []);
const militaryCount = Object.entries(aircraftByCategory)
const _militaryCount = Object.entries(aircraftByCategory)
.filter(([cat]) => cat !== 'civilian' && cat !== 'unknown')
.reduce((sum, [, c]) => sum + c, 0);
void _militaryCount; // 이란 탭에서 사용 가능 — 해외시설 분리 후 미사용
return (
<div className="layer-panel">
@ -186,7 +204,8 @@ export function LayerPanel({
{/* Ships tree */}
<LayerTreeItem
layerKey="ships"
label={`${t('layers.ships')} (${shipTotal})`}
label={t('layers.ships')}
count={shipTotal}
color="#fb923c"
active={layers.ships}
expandable
@ -297,7 +316,8 @@ export function LayerPanel({
<>
<LayerTreeItem
layerKey="nationality"
label={`국적 분류 (${Object.values(shipsByNationality).reduce((a, b) => a + b, 0)})`}
label="국적 분류"
count={Object.values(shipsByNationality).reduce((a, b) => a + b, 0)}
color="#8b5cf6"
active
expandable
@ -342,7 +362,8 @@ export function LayerPanel({
{/* Aircraft tree */}
<LayerTreeItem
layerKey="aircraft"
label={`${t('layers.aircraft')} (${aircraftTotal})`}
label={t('layers.aircraft')}
count={aircraftTotal}
color="#22d3ee"
active={layers.aircraft}
expandable
@ -401,7 +422,8 @@ export function LayerPanel({
{/* Satellites */}
<LayerTreeItem
layerKey="satellites"
label={`${t('layers.satellites')} (${satelliteCount})`}
label={t('layers.satellites')}
count={satelliteCount}
color="#ef4444"
active={layers.satellites}
onToggle={() => onToggle('satellites')}
@ -421,47 +443,92 @@ export function LayerPanel({
ungrouped.push(el);
}
}
// 수퍼그룹 별로 그룹 분류
const superGrouped: Record<string, string[]> = {}; // superGroup → groupNames[]
const noSuperGroup: string[] = [];
for (const groupName of Object.keys(grouped)) {
const sg = GROUP_META[groupName]?.superGroup;
if (sg) {
if (!superGrouped[sg]) superGrouped[sg] = [];
superGrouped[sg].push(groupName);
} else {
noSuperGroup.push(groupName);
}
}
const renderGroup = (groupName: string, indent = false) => {
const meta = GROUP_META[groupName] || { label: groupName, color: '#888' };
const isGroupExpanded = expanded.has(`group-${groupName}`);
const items = grouped[groupName] || [];
return (
<div key={groupName} style={indent ? { paddingLeft: 10 } : undefined}>
<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.label}
count={el.count}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
/>
))}
</div>
)}
</div>
);
};
return (
<>
{/* Grouped layers */}
{Object.entries(grouped).map(([groupName, items]) => {
const meta = GROUP_META[groupName] || { label: groupName, color: '#888' };
const isGroupExpanded = expanded.has(`group-${groupName}`);
{/* 수퍼그룹 없는 그룹들 (항공망·해양안전·국가기관망) */}
{noSuperGroup.map(g => renderGroup(g))}
{/* 수퍼그룹으로 묶인 그룹들 */}
{Object.entries(superGrouped).map(([sgName, groupNames]) => {
const sgMeta = SUPER_GROUP_META[sgName] || { label: sgName, color: '#f97316' };
const isSgExpanded = expanded.has(`supergroup-${sgName}`);
return (
<div key={groupName}>
<div key={sgName}>
<LayerTreeItem
layerKey={`group-${groupName}`}
label={meta.label}
color={meta.color}
layerKey={`supergroup-${sgName}`}
label={sgMeta.label}
color={sgMeta.color}
active
expandable
isExpanded={isGroupExpanded}
onToggle={() => toggleExpand(`group-${groupName}`)}
onExpand={() => toggleExpand(`group-${groupName}`)}
isExpanded={isSgExpanded}
onToggle={() => toggleExpand(`supergroup-${sgName}`)}
onExpand={() => toggleExpand(`supergroup-${sgName}`)}
/>
{isGroupExpanded && (
{isSgExpanded && (
<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)}
/>
))}
{groupNames.map(g => renderGroup(g, true))}
</div>
)}
</div>
);
})}
{/* Ungrouped layers */}
{/* 그룹 없는 개별 레이어 */}
{ungrouped.map(el => (
<LayerTreeItem
key={el.key}
layerKey={el.key}
label={el.count != null ? `${el.label} (${el.count})` : el.label}
label={el.label}
count={el.count}
color={el.color}
active={layers[el.key] ?? false}
onToggle={() => onToggle(el.key)}
@ -473,14 +540,54 @@ export function LayerPanel({
<div className="layer-divider" />
{/* Military only filter */}
{/* 해외시설 — 접기/펼치기 전용 (토글은 하위 항목에서 개별 제어) */}
<LayerTreeItem
layerKey="militaryOnly"
label={`${t('layers.militaryOnly')} (${militaryCount})`}
layerKey="overseas-section"
label="해외시설"
count={overseasItems?.reduce((sum, item) => {
const parentOn = layers[item.key] ? 1 : 0;
const childrenOn = item.children?.filter(c => layers[c.key]).length ?? 0;
return sum + parentOn + childrenOn;
}, 0) ?? 0}
color="#f97316"
active={layers.militaryOnly ?? false}
onToggle={() => onToggle('militaryOnly')}
active={expanded.has('overseas-section')}
expandable
isExpanded={expanded.has('overseas-section')}
onToggle={() => toggleExpand('overseas-section')}
onExpand={() => toggleExpand('overseas-section')}
/>
{expanded.has('overseas-section') && overseasItems && overseasItems.length > 0 && (
<div className="layer-tree-children">
{overseasItems.map(item => (
<div key={item.key}>
<LayerTreeItem
layerKey={item.key}
label={item.label}
color={item.color}
active={layers[item.key] ?? false}
expandable={!!item.children?.length}
isExpanded={expanded.has(`overseas-${item.key}`)}
onToggle={() => onToggle(item.key)}
onExpand={() => toggleExpand(`overseas-${item.key}`)}
/>
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
<div className="layer-tree-children">
{item.children.map(child => (
<LayerTreeItem
key={child.key}
layerKey={child.key}
label={child.label}
color={child.color}
active={layers[child.key] ?? false}
onToggle={() => onToggle(child.key)}
/>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
@ -495,6 +602,7 @@ function LayerTreeItem({
active,
expandable,
isExpanded,
count,
onToggle,
onExpand,
}: {
@ -504,6 +612,7 @@ function LayerTreeItem({
active: boolean;
expandable?: boolean;
isExpanded?: boolean;
count?: number;
onToggle: () => void;
onExpand?: () => void;
}) {
@ -523,13 +632,16 @@ function LayerTreeItem({
type="button"
className={`layer-toggle ${active ? 'active' : ''}`}
onClick={onToggle}
style={{ padding: 0, gap: '6px' }}
style={{ padding: 0, gap: '6px', flex: 1, width: '100%' }}
>
<span
className="layer-dot"
style={{ backgroundColor: active ? color : '#444' }}
/>
{label}
<span style={{ flex: 1 }}>{label}</span>
{count != null && (
<span style={{ fontSize: 9, color: 'var(--kcg-dim)', flexShrink: 0 }}>{count}</span>
)}
</button>
</div>
);

파일 보기

@ -0,0 +1,82 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES, type CnFacility } from '../../data/cnFacilities';
interface Props {
type: 'power' | 'military';
}
const SUBTYPE_META: Record<CnFacility['subType'], { color: string; icon: string; label: string }> = {
nuclear: { color: '#a855f7', icon: '☢', label: '핵발전' },
thermal: { color: '#f97316', icon: '⚡', label: '화력발전' },
naval: { color: '#3b82f6', icon: '⚓', label: '해군기지' },
airbase: { color: '#22d3ee', icon: '✈', label: '공군기지' },
army: { color: '#ef4444', icon: '★', label: '육군/사령부' },
shipyard: { color: '#f59e0b', icon: '⚙', label: '조선소' },
};
export function CnFacilityLayer({ type }: Props) {
const [popup, setPopup] = useState<CnFacility | null>(null);
const facilities = type === 'power' ? CN_POWER_PLANTS : CN_MILITARY_FACILITIES;
return (
<>
{facilities.map(f => {
const meta = SUBTYPE_META[f.subType];
return (
<Marker
key={f.id}
longitude={f.lng}
latitude={f.lat}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setPopup(f); }}
>
<div
title={f.name}
style={{
width: 18,
height: 18,
borderRadius: '50%',
background: meta.color,
border: '2px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
color: '#fff',
cursor: 'pointer',
boxShadow: `0 0 6px ${meta.color}88`,
}}
>
{meta.icon}
</div>
</Marker>
);
})}
{popup && (
<Popup
longitude={popup.lng}
latitude={popup.lat}
anchor="bottom"
onClose={() => setPopup(null)}
closeOnClick={false}
maxWidth="220px"
>
<div style={{ fontSize: 11, lineHeight: 1.6, padding: '2px 4px' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{popup.name}</div>
<div style={{ color: SUBTYPE_META[popup.subType].color, marginBottom: 4 }}>
{SUBTYPE_META[popup.subType].label}
</div>
{popup.operator && (
<div><span style={{ opacity: 0.6 }}>:</span> {popup.operator}</div>
)}
{popup.description && (
<div style={{ marginTop: 4, opacity: 0.85 }}>{popup.description}</div>
)}
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,855 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Ship, ChnPrmShipInfo, VesselAnalysisDto } from '../../types';
import { classifyFishingZone } from '../../utils/fishingAnalysis';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
import { lookupPermittedShip } from '../../services/chnPrmShip';
import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis';
// MarineTraffic 사진 캐시 (null = 없음, undefined = 미조회)
const mtPhotoCache = new Map<string, string | null>();
async function loadMarineTrafficPhoto(mmsi: string): Promise<string | null> {
if (mtPhotoCache.has(mmsi)) return mtPhotoCache.get(mmsi) ?? null;
return new Promise(resolve => {
const url = `https://photos.marinetraffic.com/ais/showphoto.aspx?mmsi=${mmsi}&size=thumb300`;
const img = new Image();
img.onload = () => { mtPhotoCache.set(mmsi, url); resolve(url); };
img.onerror = () => { mtPhotoCache.set(mmsi, null); resolve(null); };
img.src = url;
});
}
// S&P Global 이미지 캐시
const spgCache = new Map<string, string | null>();
async function loadSpgPhoto(imo: string, shipImagePath: string): Promise<string | null> {
if (spgCache.has(imo)) return spgCache.get(imo) ?? null;
try {
const res = await fetch(`/signal-batch/api/v1/shipimg/${imo}`);
if (!res.ok) throw new Error();
const data: Array<{ picId: number; path: string }> = await res.json();
const url = data.length > 0 ? `${data[0].path}_2.jpg` : `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`;
spgCache.set(imo, url);
return url;
} catch {
const fallback = `${shipImagePath.replace(/_[12]\.\w+$/, '')}_2.jpg`;
spgCache.set(imo, fallback);
return fallback;
}
}
// ── 항상 다크 테마 색상 팔레트
const C = {
bg: '#07101A',
bg2: '#0C1825',
bg3: '#112033',
panel: '#040C14',
green: '#00E676',
cyan: '#18FFFF',
amber: '#FFD740',
red: '#FF5252',
purple: '#E040FB',
ink: '#CFE2F3',
ink2: '#7EA8C4',
ink3: '#3D6480',
border: '#1A3350',
border2: '#0E2035',
} as const;
// AIS 수신 기준 선박 상태 분류 (Python 결과 없을 때 fallback)
function classifyStateFallback(ship: Ship): string {
const ageMins = (Date.now() - ship.lastSeen) / 60000;
if (ageMins > 20) return 'AIS_LOSS';
if (ship.speed <= 0.5) return 'STATIONARY';
if (ship.speed >= 5.0) return 'SAILING';
return 'FISHING';
}
// Python RiskLevel → 경보 등급 매핑
function riskToAlert(level: string): 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL' {
if (level === 'CRITICAL') return 'CRITICAL';
if (level === 'HIGH') return 'WATCH';
if (level === 'MEDIUM') return 'MONITOR';
return 'NORMAL';
}
function stateLabel(s: string): string {
const map: Record<string, string> = {
FISHING: '조업중', SAILING: '항행중', STATIONARY: '정박', AIS_LOSS: 'AIS소실',
};
return map[s] ?? s;
}
function zoneLabel(z: string): string {
const map: Record<string, string> = {
TERRITORIAL: '영해(침범!)', CONTIGUOUS: '접속수역', EEZ: 'EEZ', BEYOND: 'EEZ외측',
};
return map[z] ?? z;
}
interface ProcessedVessel {
ship: Ship;
zone: string;
state: string;
alert: 'CRITICAL' | 'WATCH' | 'MONITOR' | 'NORMAL';
vtype: string;
cluster: string;
}
interface LogEntry {
ts: string;
mmsi: string;
name: string;
type: string;
level: 'critical' | 'watch' | 'info';
}
interface Props {
ships: Ship[];
vesselAnalysis?: UseVesselAnalysisResult;
onClose: () => void;
}
const PIPE_STEPS = [
{ num: '01', name: 'AIS 전처리' },
{ num: '02', name: '행동 상태 탐지' },
{ num: '03', name: '궤적 리샘플링' },
{ num: '04', name: '특징 벡터 추출' },
{ num: '05', name: '규칙 기반 분류' },
{ num: '06', name: 'BIRCH 군집화' },
{ num: '07', name: '계절 활동 분석' },
];
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
const [activeFilter, setActiveFilter] = useState('ALL');
const [search, setSearch] = useState('');
const [selectedMmsi, setSelectedMmsi] = useState<string | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [pipeStep, setPipeStep] = useState(0);
const [tick, setTick] = useState(0);
// 중국 어선만 필터
const cnFishing = useMemo(() => ships.filter(s => {
if (s.flag !== 'CN') return false;
const cat = getMarineTrafficCategory(s.typecode, s.category);
return cat === 'fishing' || s.category === 'fishing';
}), [ships]);
// 선박 데이터 처리 — Python 분석 결과 우선, 없으면 GeoJSON 수역 + AIS fallback
const processed = useMemo((): ProcessedVessel[] => {
return cnFishing.map(ship => {
const dto = analysisMap.get(ship.mmsi);
// 수역: Python → GeoJSON 폴리곤 fallback
let zone: string;
if (dto) {
zone = dto.algorithms.location.zone;
} else {
const zoneInfo = classifyFishingZone(ship.lat, ship.lng);
zone = zoneInfo.zone === 'OUTSIDE' ? 'EEZ_OR_BEYOND' : zoneInfo.zone;
}
// 행동 상태: Python → AIS fallback
const state = dto?.algorithms.activity.state ?? classifyStateFallback(ship);
// 경보 등급: Python 위험도 직접 사용
const alert = dto ? riskToAlert(dto.algorithms.riskScore.level) : 'NORMAL';
// 어구 분류: Python classification
const vtype = dto?.classification.vesselType ?? 'UNKNOWN';
// 클러스터: Python cluster ID
const clusterId = dto?.algorithms.cluster.clusterId ?? -1;
const cluster = clusterId >= 0 ? `C-${String(clusterId).padStart(2, '0')}` : '—';
return { ship, zone, state, alert, vtype, cluster };
});
}, [cnFishing, analysisMap]);
// 필터 + 정렬
const displayed = useMemo(() => {
return processed
.filter(v => {
if (activeFilter === 'CRITICAL' && v.alert !== 'CRITICAL') return false;
if (activeFilter === 'FISHING' && v.state !== 'FISHING') return false;
if (activeFilter === 'AIS_LOSS' && v.state !== 'AIS_LOSS') return false;
if (activeFilter === 'TERRITORIAL' && v.zone !== 'TERRITORIAL') return false;
if (search && !v.ship.mmsi.includes(search) && !v.ship.name.toLowerCase().includes(search)) return false;
return true;
})
.sort((a, b) => ALERT_ORDER[a.alert] - ALERT_ORDER[b.alert]);
}, [processed, activeFilter, search]);
// 통계 — Python 분석 결과 기반
const stats = useMemo(() => {
let gpsAnomaly = 0;
for (const v of processed) {
const dto = analysisMap.get(v.ship.mmsi);
if (dto && dto.algorithms.gpsSpoofing.spoofingScore > 0.5) gpsAnomaly++;
}
return {
total: processed.length,
territorial: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
fishing: processed.filter(v => v.state === 'FISHING').length,
aisLoss: processed.filter(v => v.state === 'AIS_LOSS' || v.state === 'UNKNOWN').length,
gpsAnomaly,
clusters: new Set(processed.filter(v => v.cluster !== '—').map(v => v.cluster)).size,
trawl: processed.filter(v => v.vtype === 'TRAWL').length,
purse: processed.filter(v => v.vtype === 'PURSE').length,
};
}, [processed, analysisMap]);
// 구역별 카운트 — Python zone 분류 기반
const zoneCounts = useMemo(() => ({
terr: processed.filter(v => v.zone === 'TERRITORIAL_SEA' || v.zone === 'ZONE_I').length,
cont: processed.filter(v => v.zone === 'CONTIGUOUS_ZONE' || v.zone === 'ZONE_II').length,
eez: processed.filter(v => v.zone === 'EEZ_OR_BEYOND' || v.zone === 'ZONE_III' || v.zone === 'ZONE_IV').length,
beyond: processed.filter(v => !['TERRITORIAL_SEA', 'CONTIGUOUS_ZONE', 'EEZ_OR_BEYOND', 'ZONE_I', 'ZONE_II', 'ZONE_III', 'ZONE_IV'].includes(v.zone)).length,
}), [processed]);
// 초기 경보 로그 생성
useEffect(() => {
const initLogs: LogEntry[] = processed
.filter(v => v.alert === 'CRITICAL' || v.alert === 'WATCH')
.slice(0, 10)
.map((v, i) => {
const t = new Date(Date.now() - i * 4 * 60000);
const ts = t.toTimeString().slice(0, 8);
const type =
v.zone === 'TERRITORIAL' ? '영해 내 불법조업 탐지' :
v.state === 'AIS_LOSS' ? 'AIS 신호 소실 — Dark Vessel 의심' :
'접속수역 조업 행위 감지';
return { ts, mmsi: v.ship.mmsi, name: v.ship.name || '(Unknown)', type, level: v.alert === 'CRITICAL' ? 'critical' : 'watch' };
});
setLogs(initLogs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// AI 파이프라인 애니메이션
useEffect(() => {
const t = setInterval(() => setPipeStep(s => s + 1), 1200);
return () => clearInterval(t);
}, []);
// 시계 tick
useEffect(() => {
const t = setInterval(() => setTick(s => s + 1), 1000);
return () => clearInterval(t);
}, []);
void tick; // used to force re-render for clock
// Escape 키 닫기
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const selectedVessel = useMemo(() =>
selectedMmsi ? processed.find(v => v.ship.mmsi === selectedMmsi) ?? null : null,
[selectedMmsi, processed],
);
// 허가 정보
const [permitStatus, setPermitStatus] = useState<'idle' | 'loading' | 'found' | 'not-found'>('idle');
const [permitData, setPermitData] = useState<ChnPrmShipInfo | null>(null);
// 선박 사진
const [photoUrl, setPhotoUrl] = useState<string | null | undefined>(undefined); // undefined=로딩, null=없음
useEffect(() => {
if (!selectedVessel) return;
const { ship } = selectedVessel;
// 허가 조회
setPermitStatus('loading');
setPermitData(null);
lookupPermittedShip(ship.mmsi).then(data => {
setPermitData(data);
setPermitStatus(data ? 'found' : 'not-found');
});
// 사진 로드: S&P Global 우선, 없으면 MarineTraffic
setPhotoUrl(undefined);
if (ship.imo && ship.shipImagePath) {
loadSpgPhoto(ship.imo, ship.shipImagePath).then(url => {
if (url) { setPhotoUrl(url); return; }
loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl);
});
} else {
loadMarineTrafficPhoto(ship.mmsi).then(setPhotoUrl);
}
}, [selectedMmsi]); // eslint-disable-line react-hooks/exhaustive-deps
const addLog = useCallback((mmsi: string, name: string, type: string, level: 'critical' | 'watch') => {
const ts = new Date().toTimeString().slice(0, 8);
setLogs(prev => [{ ts, mmsi, name, type, level }, ...prev].slice(0, 60));
}, []);
const downloadCsv = useCallback(() => {
const headers = ['MMSI', '선명', '위도', '경도', 'SOG(kt)', '침로(°)', '상태', '선종', '구역', '클러스터', '경보등급', '마지막수신(분전)'];
const rows = processed.map(v => {
const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000);
return [
v.ship.mmsi,
v.ship.name || '',
v.ship.lat.toFixed(5),
v.ship.lng.toFixed(5),
v.state === 'AIS_LOSS' ? '' : v.ship.speed.toFixed(1),
v.state === 'AIS_LOSS' ? '' : String(v.ship.course),
stateLabel(v.state),
v.vtype,
zoneLabel(v.zone),
v.cluster,
v.alert,
String(ageMins),
].map(s => `"${s}"`).join(',');
});
const csv = [headers.join(','), ...rows].join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cn_fishing_vessels_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [processed]);
// 색상 헬퍼
const alertColor = (al: string) => ({ CRITICAL: C.red, WATCH: C.amber, MONITOR: C.cyan, NORMAL: C.green }[al] ?? C.ink3);
const zoneColor = (z: string) => ({ TERRITORIAL: C.red, CONTIGUOUS: C.amber, EEZ: C.cyan, BEYOND: C.green }[z] ?? C.ink3);
const stateColor = (s: string) => ({ FISHING: C.amber, SAILING: C.cyan, STATIONARY: C.green, AIS_LOSS: C.red }[s] ?? C.ink3);
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 2000,
background: 'rgba(2,6,14,0.96)',
display: 'flex', flexDirection: 'column',
fontFamily: "'IBM Plex Mono', 'Noto Sans KR', monospace",
}}>
{/* ── 헤더 */}
<div style={{
background: C.panel,
borderBottom: `1px solid ${C.border}`,
padding: '10px 20px',
display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0,
}}>
<span style={{ color: C.green, fontSize: 9, letterSpacing: 3 }}> FIELD ANALYSIS</span>
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: 1 }}> </span>
<span style={{ color: C.ink3, fontSize: 10 }}>AIS · · BIRCH · Shepperson(2017) · Yan et al.(2022)</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 16, alignItems: 'center' }}>
<span style={{ color: C.green, fontSize: 10, display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: C.green, display: 'inline-block', animation: 'pulse 1.5s ease-in-out infinite' }} />
LIVE
</span>
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
<button
type="button"
onClick={onClose}
style={{
background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`,
color: C.red, padding: '4px 14px', cursor: 'pointer',
fontSize: 11, borderRadius: 2, fontFamily: 'inherit',
}}
>
</button>
</div>
</div>
{/* ── 통계 스트립 */}
<div style={{
display: 'flex', gap: 8, padding: '8px 12px',
background: C.bg, flexShrink: 0,
borderBottom: `1px solid ${C.border}`,
}}>
{[
{ label: '총 탐지 어선', val: stats.total, color: C.cyan, sub: 'AIS 수신 기준' },
{ label: '영해 침범', val: stats.territorial, color: C.red, sub: '12NM 이내' },
{ label: '조업 중', val: stats.fishing, color: C.amber, sub: 'SOG 0.55.0kt' },
{ label: 'AIS 소실', val: stats.aisLoss, color: C.red, sub: '>20분 미수신' },
{ label: 'GPS 이상', val: stats.gpsAnomaly, color: C.purple, sub: 'BD-09 스푸핑 50%↑' },
{ label: '집단 클러스터', val: stats.clusters, color: C.amber, sub: 'DBSCAN 군집' },
{ label: '트롤어선', val: stats.trawl, color: C.purple, sub: 'Python 분류' },
{ label: '선망어선', val: stats.purse, color: C.cyan, sub: 'Python 분류' },
].map(({ label, val, color, sub }) => (
<div key={label} style={{
flex: 1, background: C.bg2, border: `1px solid ${C.border}`,
borderRadius: 3, padding: '8px 10px', textAlign: 'center',
borderTop: `2px solid ${color}`,
}}>
<div style={{ fontSize: 9, color: C.ink3, letterSpacing: 1 }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color, lineHeight: 1.2 }}>{val}</div>
<div style={{ fontSize: 9, color: C.ink3 }}>{sub}</div>
</div>
))}
</div>
{/* ── 메인 그리드 */}
<div style={{
display: 'flex', flex: 1, overflow: 'hidden',
background: C.bg,
}}>
{/* ── 좌측 패널: 구역 현황 + AI 파이프라인 */}
<div style={{
width: 240, flexShrink: 0,
background: C.panel, borderRight: `1px solid ${C.border}`,
overflow: 'auto', padding: '10px 12px',
}}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 8, paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{([
{ label: '영해 (12NM)', count: zoneCounts.terr, color: C.red, sub: '즉시 퇴거 명령 필요' },
{ label: '접속수역 (24NM)', count: zoneCounts.cont, color: C.amber, sub: '조업 행위 집중 모니터링' },
{ label: 'EEZ 내측', count: zoneCounts.eez, color: C.amber, sub: '조업밀도 핫스팟 포함' },
{ label: 'EEZ 외측', count: zoneCounts.beyond, color: C.green, sub: '정상 모니터링' },
] as const).map(({ label, count, color, sub }) => {
const max = Math.max(processed.length, 1);
return (
<div key={label} style={{ marginBottom: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
<span style={{ fontSize: 10, color }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 700, color }}>{count}</span>
</div>
<div style={{ height: 4, background: C.border2, borderRadius: 2, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${Math.min((count / max) * 100, 100)}%`, background: color, borderRadius: 2, transition: 'width 0.5s' }} />
</div>
<div style={{ fontSize: 9, color: C.ink3, marginTop: 2 }}>{sub}</div>
</div>
);
})}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
AI
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{PIPE_STEPS.map((step, idx) => {
const isRunning = idx === pipeStep % PIPE_STEPS.length;
return (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: isRunning ? 'rgba(0,230,118,0.15)' : 'rgba(0,230,118,0.06)',
border: `1px solid ${isRunning ? C.green : C.border}`,
color: isRunning ? C.green : C.ink3,
fontWeight: isRunning ? 700 : 400,
}}>
{isRunning ? 'PROC' : 'OK'}
</span>
</div>
);
})}
{[
{ num: 'GPS', name: 'BD-09 변환', status: 'STANDBY', color: C.ink3 },
{ num: 'NRD', name: '레이더 교차검증', status: '미연동', color: C.ink3 },
].map(step => (
<div key={step.num} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 9, color: C.ink3, width: 20 }}>{step.num}</span>
<span style={{ fontSize: 10, color: C.ink, flex: 1 }}>{step.name}</span>
<span style={{
fontSize: 8, padding: '1px 6px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: step.color,
}}>
{step.status}
</span>
</div>
))}
{/* 알고리즘 기준 요약 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, margin: '12px 0 8px', paddingBottom: 6, borderBottom: `1px solid ${C.border}` }}>
</div>
{[
{ label: '위치 판정', val: 'Haversine + 기선', color: C.ink2 },
{ label: '조업 패턴', val: 'UCAF/UCFT SOG', color: C.ink2 },
{ label: 'AIS 소실', val: '>20분 미수신', color: C.amber },
{ label: 'GPS 조작', val: 'BD-09 좌표계', color: C.purple },
{ label: '클러스터', val: 'DBSCAN 3NM (Python)', color: C.ink2 },
{ label: '선종 분류', val: 'Python 7단계 파이프라인', color: C.green },
].map(({ label, val, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 9, color }}>{val}</span>
</div>
))}
</div>
{/* ── 중앙 패널: 선박 테이블 */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{/* 필터 바 */}
<div style={{
display: 'flex', gap: 6, padding: '8px 12px', alignItems: 'center',
background: C.bg2, borderBottom: `1px solid ${C.border}`, flexShrink: 0,
flexWrap: 'wrap',
}}>
{[
{ key: 'ALL', label: '전체' },
{ key: 'CRITICAL', label: '긴급 경보' },
{ key: 'FISHING', label: '조업 중' },
{ key: 'AIS_LOSS', label: 'AIS 소실' },
{ key: 'TERRITORIAL', label: '영해 내' },
].map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setActiveFilter(key)}
style={{
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
borderRadius: 2, fontFamily: 'inherit',
background: activeFilter === key ? 'rgba(0,230,118,0.15)' : C.bg3,
border: `1px solid ${activeFilter === key ? C.green : C.border}`,
color: activeFilter === key ? C.green : C.ink2,
}}
>
{label}
</button>
))}
<input
value={search}
onChange={e => setSearch(e.target.value.toLowerCase())}
placeholder="MMSI / 선명 검색..."
style={{
flex: 1, minWidth: 120,
background: C.bg3, border: `1px solid ${C.border}`,
color: C.ink, padding: '3px 10px', fontSize: 10,
borderRadius: 2, outline: 'none', fontFamily: 'inherit',
}}
/>
<span style={{ color: C.ink3, fontSize: 10, whiteSpace: 'nowrap' }}>
: <span style={{ color: C.cyan }}>{displayed.length}</span>
</span>
<button
type="button"
onClick={downloadCsv}
title="CSV 다운로드"
style={{
padding: '3px 10px', fontSize: 10, cursor: 'pointer',
borderRadius: 2, fontFamily: 'inherit', whiteSpace: 'nowrap',
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
}}
>
CSV
</button>
</div>
{/* 테이블 */}
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0 }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: C.panel, zIndex: 1 }}>
{['AIS', 'MMSI', '선명', '위도', '경도', 'SOG', '침로', '상태', '선종', '구역', '클러스터', '경보', '수신'].map(h => (
<th key={h} style={{
padding: '6px 8px', fontSize: 9, color: C.ink3, fontWeight: 600,
letterSpacing: 1, textAlign: 'left',
borderBottom: `1px solid ${C.border}`, whiteSpace: 'nowrap',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{displayed.slice(0, 120).map(v => {
const rowBg =
v.alert === 'CRITICAL' ? 'rgba(255,82,82,0.08)' :
v.alert === 'WATCH' ? 'rgba(255,215,64,0.05)' :
v.alert === 'MONITOR' ? 'rgba(24,255,255,0.04)' :
'transparent';
const isSelected = v.ship.mmsi === selectedMmsi;
const ageMins = Math.floor((Date.now() - v.ship.lastSeen) / 60000);
return (
<tr
key={v.ship.mmsi}
onClick={() => setSelectedMmsi(v.ship.mmsi)}
style={{
background: isSelected ? 'rgba(0,230,118,0.08)' : rowBg,
cursor: 'pointer',
outline: isSelected ? `1px solid ${C.green}` : undefined,
}}
>
<td style={{ padding: '5px 8px' }}>
<span style={{
display: 'inline-block', width: 7, height: 7, borderRadius: '50%',
background: v.state === 'AIS_LOSS' ? C.red : C.green,
}} />
</td>
<td style={{ fontSize: 10, color: C.cyan, whiteSpace: 'nowrap', padding: '5px 8px' }}>{v.ship.mmsi}</td>
<td style={{ fontSize: 10, color: '#fff', padding: '5px 8px', maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{v.ship.name || '(Unknown)'}
</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lat.toFixed(3)}°N</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>{v.ship.lng.toFixed(3)}°E</td>
<td style={{ fontSize: 10, color: C.amber, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{v.state === 'AIS_LOSS' ? '—' : `${v.ship.speed.toFixed(1)}kt`}
</td>
<td style={{ fontSize: 10, color: C.ink2, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{v.state !== 'AIS_LOSS' ? `${v.ship.course}°` : '—'}
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: `${stateColor(v.state)}22`,
border: `1px solid ${stateColor(v.state)}66`,
color: stateColor(v.state),
}}>
{stateLabel(v.state)}
</span>
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: 'rgba(24,255,255,0.08)', border: `1px solid ${C.border}`, color: C.cyan,
}}>
{v.vtype}
</span>
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2, whiteSpace: 'nowrap',
background: `${zoneColor(v.zone)}15`,
border: `1px solid ${zoneColor(v.zone)}55`,
color: zoneColor(v.zone),
}}>
{zoneLabel(v.zone)}
</span>
</td>
<td style={{ padding: '5px 8px', fontSize: 10, color: v.cluster !== '—' ? C.purple : C.ink3 }}>
{v.cluster}
</td>
<td style={{ padding: '5px 8px' }}>
<span style={{
fontSize: 9, padding: '2px 5px', borderRadius: 2,
background: `${alertColor(v.alert)}15`,
border: `1px solid ${alertColor(v.alert)}55`,
color: alertColor(v.alert),
}}>
{v.alert}
</span>
</td>
<td style={{ fontSize: 9, color: C.ink3, padding: '5px 8px', whiteSpace: 'nowrap' }}>
{ageMins < 60 ? `${ageMins}분전` : `${Math.floor(ageMins / 60)}시간전`}
</td>
</tr>
);
})}
{displayed.length === 0 && (
<tr>
<td colSpan={13} style={{ padding: 32, textAlign: 'center', color: C.ink3, fontSize: 11 }}>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 하단 범례 */}
<div style={{
display: 'flex', gap: 16, padding: '6px 12px', alignItems: 'center',
background: C.bg2, borderTop: `1px solid ${C.border}`,
fontSize: 10, flexShrink: 0, flexWrap: 'wrap',
}}>
{[
{ color: C.red, label: 'CRITICAL — 즉시대응' },
{ color: C.amber, label: 'WATCH — 집중모니터링' },
{ color: C.cyan, label: 'MONITOR — 주시' },
{ color: C.green, label: 'NORMAL — 정상' },
].map(({ color, label }) => (
<span key={label} style={{ display: 'flex', alignItems: 'center', gap: 5, color: C.ink2 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
{label}
</span>
))}
<span style={{ marginLeft: 'auto', color: C.ink3, fontSize: 9 }}>
AIS 4 | Python 7 | DBSCAN 3NM | GeoJSON
</span>
</div>
</div>
{/* ── 우측 패널: 선박 상세 + 허가 정보 + 사진 + 경보 로그 */}
<div style={{
width: 280, flexShrink: 0,
background: C.panel, borderLeft: `1px solid ${C.border}`,
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* 패널 헤더 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '10px 12px 6px', borderBottom: `1px solid ${C.border}`, flexShrink: 0 }}>
<span style={{ float: 'right', color: C.green, fontSize: 8 }}></span>
</div>
{/* 스크롤 영역: 상세 + 허가 + 사진 */}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{selectedVessel ? (
<>
{/* 기본 상세 필드 */}
<div style={{ padding: '8px 12px' }}>
{[
{ label: 'MMSI', val: selectedVessel.ship.mmsi, color: C.cyan },
{ label: '선명', val: selectedVessel.ship.name || '(Unknown)', color: '#fff' },
{ label: '위치', val: `${selectedVessel.ship.lat.toFixed(4)}°N ${selectedVessel.ship.lng.toFixed(4)}°E`, color: C.ink },
{ label: '속도 / 침로', val: `${selectedVessel.ship.speed.toFixed(1)}kt ${selectedVessel.ship.course}°`, color: C.amber },
{ label: '행동 상태', val: stateLabel(selectedVessel.state), color: stateColor(selectedVessel.state) },
{ label: '선종 (Python)', val: selectedVessel.vtype, color: C.ink },
{ label: '현재 구역', val: zoneLabel(selectedVessel.zone), color: zoneColor(selectedVessel.zone) },
{ label: '클러스터', val: selectedVessel.cluster, color: selectedVessel.cluster !== '—' ? C.purple : C.ink3 },
{ label: '위험도', val: selectedVessel.alert, color: alertColor(selectedVessel.alert) },
...(() => {
const dto = analysisMap.get(selectedVessel.ship.mmsi);
if (!dto) return [{ label: 'AI 분석', val: '미분석', color: C.ink3 }];
return [
{ label: '위험 점수', val: `${dto.algorithms.riskScore.score}`, color: alertColor(selectedVessel.alert) },
{ label: 'GPS 스푸핑', val: `${Math.round(dto.algorithms.gpsSpoofing.spoofingScore * 100)}%`, color: dto.algorithms.gpsSpoofing.spoofingScore > 0.5 ? C.red : C.green },
{ label: 'AIS 공백', val: dto.algorithms.darkVessel.isDark ? `${Math.round(dto.algorithms.darkVessel.gapDurationMin)}` : '정상', color: dto.algorithms.darkVessel.isDark ? C.red : C.green },
{ label: '선단 역할', val: dto.algorithms.fleetRole.role, color: dto.algorithms.fleetRole.isLeader ? C.amber : C.ink2 },
];
})(),
].map(({ label, val, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 10, color, fontWeight: 600, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
type="button"
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', '대응 명령 발령', 'critical')}
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,82,82,0.1)', border: `1px solid rgba(255,82,82,0.4)`, color: C.red, borderRadius: 2, fontFamily: 'inherit' }}
> </button>
<button
type="button"
onClick={() => addLog(selectedVessel.ship.mmsi, selectedVessel.ship.name || '', 'ENG/드론 투입 명령', 'watch')}
style={{ flex: 1, padding: '5px 0', fontSize: 9, cursor: 'pointer', background: 'rgba(255,215,64,0.08)', border: `1px solid rgba(255,215,64,0.3)`, color: C.amber, borderRadius: 2, fontFamily: 'inherit' }}
>ENG/</button>
</div>
</div>
{/* ── 허가 정보 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}> </div>
{/* 허가 여부 배지 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 9, color: C.ink3 }}> </span>
{permitStatus === 'loading' && (
<span style={{ fontSize: 9, color: C.ink3 }}> ...</span>
)}
{permitStatus === 'found' && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(0,230,118,0.15)', border: `1px solid ${C.green}`, color: C.green }}>
</span>
)}
{permitStatus === 'not-found' && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 2, background: 'rgba(255,82,82,0.12)', border: `1px solid ${C.red}`, color: C.red }}>
</span>
)}
</div>
{/* 허가 내역 (데이터 있을 때) */}
{permitStatus === 'found' && permitData && (
<div style={{ background: C.bg2, border: `1px solid ${C.border}`, borderRadius: 3, padding: '7px 10px' }}>
{[
{ label: '선명', val: permitData.name },
{ label: '선종', val: permitData.vesselType },
{ label: 'IMO', val: String(permitData.imo || '—') },
{ label: '호출부호', val: permitData.callsign || '—' },
{ label: '길이/폭', val: `${permitData.length ?? 0}m / ${permitData.width ?? 0}m` },
{ label: '흘수', val: permitData.draught ? `${permitData.draught}m` : '—' },
{ label: '목적지', val: permitData.destination || '—' },
{ label: '상태', val: permitData.status || '—' },
].map(({ label, val }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: `1px solid ${C.border2}` }}>
<span style={{ fontSize: 9, color: C.ink3 }}>{label}</span>
<span style={{ fontSize: 9, color: C.ink, textAlign: 'right', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
</div>
))}
</div>
)}
{/* 미등록 안내 */}
{permitStatus === 'not-found' && (
<div style={{ background: 'rgba(255,82,82,0.06)', border: `1px solid rgba(255,82,82,0.2)`, borderRadius: 3, padding: '7px 10px' }}>
<div style={{ fontSize: 9, color: '#FF8A80', lineHeight: 1.6 }}>
DB에 .<br />
</div>
</div>
)}
</div>
{/* ── 선박 사진 */}
<div style={{ borderTop: `1px solid ${C.border}`, padding: '8px 12px 12px' }}>
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, marginBottom: 7 }}> </div>
<div style={{
width: '100%', height: 140,
background: C.bg3, border: `1px solid ${C.border}`,
borderRadius: 3, overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{photoUrl === undefined && (
<span style={{ fontSize: 9, color: C.ink3 }}> ...</span>
)}
{photoUrl === null && (
<span style={{ fontSize: 9, color: C.ink3 }}> </span>
)}
{photoUrl && (
<img
src={photoUrl}
alt={selectedVessel.ship.name || '선박'}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoUrl(null)}
/>
)}
</div>
{photoUrl && (
<div style={{ fontSize: 8, color: C.ink3, marginTop: 4, textAlign: 'right' }}>
© MarineTraffic / S&P Global
</div>
)}
</div>
</>
) : (
<div style={{ padding: '24px 0', textAlign: 'center', color: C.ink3, fontSize: 10 }}>
</div>
)}
</div>
{/* 경보 로그 — 하단 고정 */}
<div style={{ fontSize: 9, letterSpacing: 2, color: C.cyan, padding: '6px 12px', borderTop: `1px solid ${C.border}`, borderBottom: `1px solid ${C.border}`, flexShrink: 0, display: 'flex', justifyContent: 'space-between' }}>
<span> </span>
<span style={{ color: C.ink3, fontSize: 8 }}>{logs.length}</span>
</div>
<div style={{ flex: '0 0 160px', overflow: 'auto' }}>
{logs.map((log, i) => (
<div key={i} style={{
padding: '5px 12px',
borderBottom: `1px solid ${C.border2}`,
borderLeft: `2px solid ${log.level === 'critical' ? C.red : log.level === 'watch' ? C.amber : C.cyan}`,
}}>
<div style={{ fontSize: 9, color: C.ink3 }}>{log.ts}</div>
<div style={{ fontSize: 10, lineHeight: 1.4, color: log.level === 'critical' ? '#FF8A80' : log.level === 'watch' ? '#FFE57F' : '#80DEEA' }}>
<span style={{ color: C.cyan }}>{log.mmsi}</span> {log.name} {log.type}
</div>
</div>
))}
{logs.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: C.ink3, fontSize: 10 }}> </div>
)}
</div>
</div>
</div>
</div>
);
}

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Source, Layer } from 'react-map-gl/maplibre';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre';
import type { GeoJSON } from 'geojson';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import type { Ship, VesselAnalysisDto } from '../../types';
import { fetchFleetCompanies } from '../../services/vesselAnalysis';
import type { FleetCompany } from '../../services/vesselAnalysis';
@ -11,6 +12,12 @@ export interface SelectedGearGroupData {
groupName: string;
}
export interface SelectedFleetData {
clusterId: number;
ships: Ship[];
companyName: string;
}
interface Props {
ships: Ship[];
analysisMap: Map<string, VesselAnalysisDto>;
@ -18,6 +25,7 @@ interface Props {
onShipSelect?: (mmsi: string) => void;
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
onSelectedGearChange?: (data: SelectedGearGroupData | null) => void;
onSelectedFleetChange?: (data: SelectedFleetData | null) => void;
}
// 두 벡터의 외적 (2D) — Graham scan에서 왼쪽 회전 여부 판별
@ -90,18 +98,135 @@ interface ClusterLineFeature {
type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature;
export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange }: Props) {
export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) {
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
const [expanded, setExpanded] = useState(true);
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
// 폴리곤 호버 툴팁
const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null);
const { current: mapRef } = useMap();
const registeredRef = useRef(false);
// dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조)
const dataRef = useRef<{ clusters: Map<number, string[]>; shipMap: Map<string, Ship>; gearGroupMap: Map<string, { parent: Ship | null; gears: Ship[] }>; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom });
useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
// ── 맵 폴리곤 클릭/호버 이벤트 등록
useEffect(() => {
const map = mapRef?.getMap();
if (!map || registeredRef.current) return;
const fleetLayers = ['fleet-cluster-fill-layer'];
const gearLayers = ['gear-cluster-fill-layer'];
const allLayers = [...fleetLayers, ...gearLayers];
const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; };
const onFleetEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const cid = feat.properties?.clusterId as number | undefined;
if (cid != null) {
setHoveredFleetId(cid);
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'fleet', id: cid });
}
};
const onFleetLeave = () => {
setCursor('');
setHoveredFleetId(null);
setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev);
};
const onFleetClick = (e: MapLayerMouseEvent) => {
const feat = e.features?.[0];
if (!feat) return;
const cid = feat.properties?.clusterId as number | undefined;
if (cid == null) return;
const d = dataRef.current;
setExpandedFleet(prev => prev === cid ? null : cid);
setExpanded(true);
const mmsiList = d.clusters.get(cid) ?? [];
if (mmsiList.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const mmsi of mmsiList) {
const ship = d.shipMap.get(mmsi);
if (!ship) continue;
if (ship.lat < minLat) minLat = ship.lat;
if (ship.lat > maxLat) maxLat = ship.lat;
if (ship.lng < minLng) minLng = ship.lng;
if (ship.lng > maxLng) maxLng = ship.lng;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
};
const onGearEnter = (e: MapLayerMouseEvent) => {
setCursor('pointer');
const feat = e.features?.[0];
if (!feat) return;
const name = feat.properties?.name as string | undefined;
if (name) {
setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name });
}
};
const onGearLeave = () => {
setCursor('');
setHoverTooltip(prev => prev?.type === 'gear' ? null : prev);
};
const onGearClick = (e: MapLayerMouseEvent) => {
const feat = e.features?.[0];
if (!feat) return;
const name = feat.properties?.name as string | undefined;
if (!name) return;
const d = dataRef.current;
setSelectedGearGroup(prev => prev === name ? null : name);
setExpandedGearGroup(name);
setExpanded(true);
const entry = d.gearGroupMap.get(name);
if (!entry) return;
const all: Ship[] = [...entry.gears];
if (entry.parent) all.push(entry.parent);
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const s of all) {
if (s.lat < minLat) minLat = s.lat;
if (s.lat > maxLat) maxLat = s.lat;
if (s.lng < minLng) minLng = s.lng;
if (s.lng > maxLng) maxLng = s.lng;
}
if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
};
const register = () => {
const ready = allLayers.every(id => map.getLayer(id));
if (!ready) return;
registeredRef.current = true;
for (const id of fleetLayers) {
map.on('mouseenter', id, onFleetEnter);
map.on('mouseleave', id, onFleetLeave);
map.on('click', id, onFleetClick);
}
for (const id of gearLayers) {
map.on('mouseenter', id, onGearEnter);
map.on('mouseleave', id, onGearLeave);
map.on('click', id, onGearClick);
}
};
register();
if (!registeredRef.current) {
const interval = setInterval(() => {
register();
if (registeredRef.current) clearInterval(interval);
}, 500);
return () => clearInterval(interval);
}
}, [mapRef]);
// 선박명 → mmsi 맵 (어구 매칭용)
const gearsByParent = useMemo(() => {
const map = new Map<string, Ship[]>(); // parent_mmsi → gears
@ -180,6 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
return map;
}, [ships]);
// stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신
dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom };
// 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용)
useEffect(() => {
if (!selectedGearGroup) {
@ -194,6 +322,22 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
}
}, [selectedGearGroup, gearGroupMap, onSelectedGearChange]);
// 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용)
useEffect(() => {
if (expandedFleet === null) {
onSelectedFleetChange?.(null);
return;
}
const mmsiList = clusters.get(expandedFleet) ?? [];
const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s);
const company = companies.get(expandedFleet);
onSelectedFleetChange?.({
clusterId: expandedFleet,
ships: fleetShips,
companyName: company?.nameCn || `선단 #${expandedFleet}`,
});
}, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]);
// 비허가 어구 클러스터 GeoJSON
const gearClusterGeoJson = useMemo((): GeoJSON => {
const features: GeoJSON.Feature[] = [];
@ -457,6 +601,67 @@ export function FleetClusterLayer({ ships, analysisMap, clusters, onShipSelect,
/>
</Source>
{/* 폴리곤 호버 툴팁 */}
{hoverTooltip && (() => {
if (hoverTooltip.type === 'fleet') {
const cid = hoverTooltip.id as number;
const mmsiList = clusters.get(cid) ?? [];
const company = companies.get(cid);
const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0);
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
{company?.nameCn || `선단 #${cid}`}
</div>
<div style={{ color: '#94a3b8' }}> {mmsiList.length} · {gearCount}</div>
{expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => {
const s = shipMap.get(mmsi);
const dto = analysisMap.get(mmsi);
const role = dto?.algorithms.fleetRole.role ?? '';
return s ? (
<div key={mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
{role === 'LEADER' ? '★' : '·'} {s.name || mmsi} <span style={{ color: '#4a6b82' }}>{s.speed?.toFixed(1)}kt</span>
</div>
) : null;
})}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> </div>
</div>
</Popup>
);
}
if (hoverTooltip.type === 'gear') {
const name = hoverTooltip.id as string;
const entry = gearGroupMap.get(name);
if (!entry) return null;
return (
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
closeButton={false} closeOnClick={false} anchor="bottom"
className="gl-popup" maxWidth="220px"
>
<div style={{ fontFamily: 'monospace', fontSize: 10, padding: 4, color: '#e2e8f0' }}>
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}> {entry.gears.length}</span>
</div>
{entry.parent && (
<div style={{ fontSize: 9, color: '#fbbf24' }}>: {entry.parent.name || entry.parent.mmsi}</div>
)}
{selectedGearGroup === name && entry.gears.slice(0, 5).map(g => (
<div key={g.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
· {g.name || g.mmsi}
</div>
))}
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}> /</div>
</div>
</Popup>
);
}
return null;
})()}
{/* 선단 목록 패널 */}
<div style={panelStyle}>
<div style={headerStyle}>

파일 보기

@ -0,0 +1,110 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { HAZARD_FACILITIES } from '../../data/hazardFacilities';
import type { HazardFacility, HazardType } from '../../data/hazardFacilities';
interface Props {
type: HazardType;
}
const TYPE_META: Record<HazardType, { icon: string; color: string; label: string; bgColor: string }> = {
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지', bgColor: 'rgba(249,115,22,0.15)' },
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지', bgColor: 'rgba(6,182,212,0.15)' },
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크', bgColor: 'rgba(234,179,8,0.15)' },
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물하역시설', bgColor: 'rgba(239,68,68,0.15)' },
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소', bgColor: 'rgba(168,85,247,0.15)' },
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소', bgColor: 'rgba(100,116,139,0.15)' },
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소 도장시설', bgColor: 'rgba(14,165,233,0.15)' },
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장', bgColor: 'rgba(16,185,129,0.15)' },
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소', bgColor: 'rgba(148,163,184,0.15)' },
};
export function HazardFacilityLayer({ type }: Props) {
const [selected, setSelected] = useState<HazardFacility | null>(null);
const meta = TYPE_META[type];
const facilities = HAZARD_FACILITIES.filter(f => f.type === type);
return (
<>
{facilities.map(f => (
<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 4px ${meta.color}99)` }}
>
<div style={{
width: 20, height: 20, borderRadius: 4,
background: 'rgba(0,0,0,0.75)',
border: `1.5px solid ${meta.color}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11,
}}>
{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 && (
<Popup
longitude={selected.lng} latitude={selected.lat}
onClose={() => setSelected(null)} closeOnClick={false}
anchor="bottom" maxWidth="300px" className="gl-popup"
>
<div className="popup-body-sm" style={{ minWidth: 230 }}>
<div className="popup-header" style={{
background: meta.color, color: '#fff', gap: 6, padding: '6px 10px',
}}>
<span style={{ fontSize: 16 }}>{meta.icon}</span>
<strong style={{ fontSize: 13 }}>{selected.nameKo}</strong>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<span style={{
background: meta.color, color: '#fff',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
<span style={{
background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: '1px solid #ef4444',
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
</span>
</div>
<div style={{ fontSize: 11, color: '#cbd5e1', marginBottom: 6, lineHeight: 1.5 }}>
{selected.description}
</div>
<div className="popup-grid" style={{ gap: '2px 12px' }}>
{selected.address && (
<div><span className="popup-label"> : </span><strong>{selected.address}</strong></div>
)}
{selected.operator && (
<div><span className="popup-label"> : </span><strong>{selected.operator}</strong></div>
)}
{selected.capacity && (
<div><span className="popup-label"> : </span><strong>{selected.capacity}</strong></div>
)}
<div><span className="popup-label">(EN) : </span>{selected.name}</div>
</div>
<div style={{ marginTop: 6, fontSize: 10, color: '#64748b' }}>
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
</div>
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -0,0 +1,81 @@
import { useState } from 'react';
import { Marker, Popup } from 'react-map-gl/maplibre';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES, type JpFacility } from '../../data/jpFacilities';
interface Props {
type: 'power' | 'military';
}
const SUBTYPE_META: Record<JpFacility['subType'], { color: string; icon: string; label: string }> = {
nuclear: { color: '#a855f7', icon: '☢', label: '핵발전' },
thermal: { color: '#f97316', icon: '⚡', label: '화력발전' },
naval: { color: '#3b82f6', icon: '⚓', label: '해군기지' },
airbase: { color: '#22d3ee', icon: '✈', label: '공군기지' },
army: { color: '#ef4444', icon: '★', label: '육군' },
};
export function JpFacilityLayer({ type }: Props) {
const [popup, setPopup] = useState<JpFacility | null>(null);
const facilities = type === 'power' ? JP_POWER_PLANTS : JP_MILITARY_FACILITIES;
return (
<>
{facilities.map(f => {
const meta = SUBTYPE_META[f.subType];
return (
<Marker
key={f.id}
longitude={f.lng}
latitude={f.lat}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); setPopup(f); }}
>
<div
title={f.name}
style={{
width: 18,
height: 18,
borderRadius: '50%',
background: meta.color,
border: '2px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
color: '#fff',
cursor: 'pointer',
boxShadow: `0 0 6px ${meta.color}88`,
}}
>
{meta.icon}
</div>
</Marker>
);
})}
{popup && (
<Popup
longitude={popup.lng}
latitude={popup.lat}
anchor="bottom"
onClose={() => setPopup(null)}
closeOnClick={false}
maxWidth="220px"
>
<div style={{ fontSize: 11, lineHeight: 1.6, padding: '2px 4px' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{popup.name}</div>
<div style={{ color: SUBTYPE_META[popup.subType].color, marginBottom: 4 }}>
{SUBTYPE_META[popup.subType].label}
</div>
{popup.operator && (
<div><span style={{ opacity: 0.6 }}>:</span> {popup.operator}</div>
)}
{popup.description && (
<div style={{ marginTop: 4, opacity: 0.85 }}>{popup.description}</div>
)}
</div>
</Popup>
)}
</>
);
}

파일 보기

@ -20,9 +20,10 @@ import { EezLayer } from './EezLayer';
// PiracyLayer, WindFarmLayer, PortLayer, MilitaryBaseLayer, GovBuildingLayer,
// NKLaunchLayer, NKMissileEventLayer → useStaticDeckLayers로 전환됨
import { ChineseFishingOverlay } from './ChineseFishingOverlay';
// HazardFacilityLayer, CnFacilityLayer, JpFacilityLayer → useStaticDeckLayers로 전환됨
import { AnalysisOverlay } from './AnalysisOverlay';
import { FleetClusterLayer } from './FleetClusterLayer';
import type { SelectedGearGroupData } from './FleetClusterLayer';
import type { SelectedGearGroupData, SelectedFleetData } from './FleetClusterLayer';
import { FishingZoneLayer } from './FishingZoneLayer';
import { AnalysisStatsPanel } from './AnalysisStatsPanel';
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
@ -138,6 +139,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
const [trackCoords, setTrackCoords] = useState<[number, number][] | null>(null);
const [selectedGearData, setSelectedGearData] = useState<SelectedGearGroupData | null>(null);
const [selectedFleetData, setSelectedFleetData] = useState<SelectedFleetData | null>(null);
const [zoomLevel, setZoomLevel] = useState(KOREA_MAP_ZOOM);
const [staticPickInfo, setStaticPickInfo] = useState<StaticPickInfo | null>(null);
@ -173,12 +175,20 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
);
}, []);
// 줌 레벨별 아이콘/심볼 스케일 배율
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=1.0x 기준, 2단계씩 상향
// 줌 레벨별 아이콘/심볼 스케일 배율 — z4=0.8x, z6=1.0x 시작, 2단계씩 상향
const zoomScale = useMemo(() => {
if (zoomLevel <= 6) return 0.6;
if (zoomLevel <= 9) return 1.0;
if (zoomLevel <= 12) return 1.4;
return 1.8;
if (zoomLevel <= 4) return 0.8;
if (zoomLevel <= 5) return 0.9;
if (zoomLevel <= 6) return 1.0;
if (zoomLevel <= 7) return 1.2;
if (zoomLevel <= 8) return 1.5;
if (zoomLevel <= 9) return 1.8;
if (zoomLevel <= 10) return 2.2;
if (zoomLevel <= 11) return 2.5;
if (zoomLevel <= 12) return 2.8;
if (zoomLevel <= 13) return 3.5;
return 4.2;
}, [zoomLevel]);
// 불법어선 강조 — deck.gl ScatterplotLayer + TextLayer
@ -273,6 +283,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
piracy: layers.piracy ?? false,
infra: layers.infra ?? false,
infraFacilities: infra,
hazardTypes: [
...(layers.hazardPetrochemical ? ['petrochemical' as const] : []),
...(layers.hazardLng ? ['lng' as const] : []),
...(layers.hazardOilTank ? ['oilTank' as const] : []),
...(layers.hazardPort ? ['hazardPort' as const] : []),
...(layers.energyNuclear ? ['nuclear' as const] : []),
...(layers.energyThermal ? ['thermal' as const] : []),
...(layers.industryShipyard ? ['shipyard' as const] : []),
...(layers.industryWastewater ? ['wastewater' as const] : []),
...(layers.industryHeavy ? ['heavyIndustry' as const] : []),
],
cnPower: !!layers.cnPower,
cnMilitary: !!layers.cnMilitary,
jpPower: !!layers.jpPower,
jpMilitary: !!layers.jpMilitary,
onPick: (info) => setStaticPickInfo(info),
sizeScale: zoomScale,
});
@ -353,6 +378,88 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
return layers;
}, [selectedGearData, zoomScale]);
// 선택된 선단 소속 선박 강조 레이어 (deck.gl)
const selectedFleetLayers = useMemo(() => {
if (!selectedFleetData) return [];
const { ships: fleetShips, clusterId } = selectedFleetData;
if (fleetShips.length === 0) return [];
// HSL→RGB 인라인 변환 (선단 색상)
const hue = (clusterId * 137) % 360;
const h = hue / 360; const s = 0.7; const l = 0.6;
const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; return t < 1/6 ? p + (q-p)*6*t : t < 1/2 ? q : t < 2/3 ? p + (q-p)*(2/3-t)*6 : p; };
const q = l < 0.5 ? l * (1+s) : l + s - l*s; const p = 2*l - q;
const r = Math.round(hue2rgb(p, q, h + 1/3) * 255);
const g = Math.round(hue2rgb(p, q, h) * 255);
const b = Math.round(hue2rgb(p, q, h - 1/3) * 255);
const color: [number, number, number, number] = [r, g, b, 255];
const fillColor: [number, number, number, number] = [r, g, b, 80];
const result: Layer[] = [];
// 소속 선박 — 강조 원형
result.push(new ScatterplotLayer({
id: 'selected-fleet-items',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 8 * zoomScale,
getFillColor: fillColor,
getLineColor: color,
stroked: true,
filled: true,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 2,
}));
// 소속 선박 이름 라벨
result.push(new TextLayer({
id: 'selected-fleet-labels',
data: fleetShips,
getPosition: (d: Ship) => [d.lng, d.lat],
getText: (d: Ship) => {
const dto = vesselAnalysis?.analysisMap.get(d.mmsi);
const role = dto?.algorithms.fleetRole.role;
const prefix = role === 'LEADER' ? '★ ' : '';
return `${prefix}${d.name || d.mmsi}`;
},
getSize: 9 * zoomScale,
getColor: color,
getTextAnchor: 'middle' as const,
getAlignmentBaseline: 'top' as const,
getPixelOffset: [0, 12],
fontFamily: 'monospace',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [0, 0, 0, 220],
billboard: false,
characterSet: 'auto',
}));
// 리더 선박 추가 강조 (큰 외곽 링)
const leaders = fleetShips.filter(s => {
const dto = vesselAnalysis?.analysisMap.get(s.mmsi);
return dto?.algorithms.fleetRole.isLeader;
});
if (leaders.length > 0) {
result.push(new ScatterplotLayer({
id: 'selected-fleet-leaders',
data: leaders,
getPosition: (d: Ship) => [d.lng, d.lat],
getRadius: 16 * zoomScale,
getFillColor: [0, 0, 0, 0],
getLineColor: color,
stroked: true,
filled: false,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
getLineWidth: 3,
}));
}
return result;
}, [selectedFleetData, zoomScale, vesselAnalysis]);
// 분석 결과 deck.gl 레이어
const analysisActiveFilter = koreaFilters.illegalFishing ? 'illegalFishing'
: koreaFilters.darkVessel ? 'darkVessel'
@ -502,6 +609,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
{/* 정적 레이어들은 deck.gl DeckGLOverlay로 전환됨 — 아래 DOM 레이어 제거 */}
{koreaFilters.illegalFishing && <FishingZoneLayer />}
{layers.cnFishing && <ChineseFishingOverlay ships={ships} analysisMap={vesselAnalysis?.analysisMap} />}
{/* HazardFacility, CnFacility, JpFacility → useStaticDeckLayers (deck.gl GPU) 전환 완료 */}
{layers.cnFishing && vesselAnalysis && vesselAnalysis.clusters.size > 0 && (
<FleetClusterLayer
ships={allShips ?? ships}
@ -510,6 +618,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
onShipSelect={handleAnalysisShipSelect}
onFleetZoom={handleFleetZoom}
onSelectedGearChange={setSelectedGearData}
onSelectedFleetChange={setSelectedFleetData}
/>
)}
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
@ -528,30 +637,203 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
illegalFishingLabelLayer,
zoneLabelsLayer,
...selectedGearLayers,
...selectedFleetLayers,
...analysisDeckLayers,
].filter(Boolean)} />
{/* 정적 마커 클릭 Popup */}
{/* 정적 마커 클릭 Popup — 통합 리치 디자인 */}
{staticPickInfo && (() => {
const obj = staticPickInfo.object;
const kind = staticPickInfo.kind;
const lat = obj.lat ?? obj.launchLat ?? 0;
const lng = obj.lng ?? obj.launchLng ?? 0;
if (!lat || !lng) return null;
// ── kind + subType 기반 메타 결정 ──
const SUB_META: Record<string, Record<string, { icon: string; color: string; label: string }>> = {
hazard: {
petrochemical: { icon: '🏭', color: '#f97316', label: '석유화학단지' },
lng: { icon: '🔵', color: '#06b6d4', label: 'LNG저장기지' },
oilTank: { icon: '🛢️', color: '#eab308', label: '유류저장탱크' },
hazardPort: { icon: '⚠️', color: '#ef4444', label: '위험물항만하역' },
nuclear: { icon: '☢️', color: '#a855f7', label: '원자력발전소' },
thermal: { icon: '🔥', color: '#64748b', label: '화력발전소' },
shipyard: { icon: '🚢', color: '#0ea5e9', label: '조선소' },
wastewater: { icon: '💧', color: '#10b981', label: '폐수처리장' },
heavyIndustry: { icon: '⚙️', color: '#94a3b8', label: '시멘트/제철소' },
},
overseas: {
nuclear: { icon: '☢️', color: '#ef4444', label: '핵발전소' },
thermal: { icon: '🔥', color: '#f97316', label: '화력발전소' },
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
airbase: { icon: '✈️', color: '#22d3ee', label: '공군기지' },
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
shipyard:{ icon: '🚢', color: '#94a3b8', label: '조선소' },
},
militaryBase: {
naval: { icon: '⚓', color: '#3b82f6', label: '해군기지' },
airforce:{ icon: '✈️', color: '#22d3ee', label: '공군기지' },
army: { icon: '🪖', color: '#22c55e', label: '육군기지' },
missile: { icon: '🚀', color: '#ef4444', label: '미사일기지' },
joint: { icon: '⭐', color: '#f59e0b', label: '합동사령부' },
},
govBuilding: {
executive: { icon: '🏛️', color: '#f59e0b', label: '행정부' },
legislature: { icon: '🏛️', color: '#3b82f6', label: '입법부' },
military_hq: { icon: '⭐', color: '#ef4444', label: '군사령부' },
intelligence: { icon: '🔍', color: '#8b5cf6', label: '정보기관' },
foreign: { icon: '🌐', color: '#06b6d4', label: '외교부' },
maritime: { icon: '⚓', color: '#0ea5e9', label: '해양기관' },
defense: { icon: '🛡️', color: '#dc2626', label: '국방부' },
},
nkLaunch: {
icbm: { icon: '🚀', color: '#dc2626', label: 'ICBM 발사장' },
irbm: { icon: '🚀', color: '#ef4444', label: 'IRBM/MRBM' },
srbm: { icon: '🎯', color: '#f97316', label: '단거리탄도미사일' },
slbm: { icon: '🔱', color: '#3b82f6', label: 'SLBM 발사' },
cruise: { icon: '✈️', color: '#8b5cf6', label: '순항미사일' },
artillery: { icon: '💥', color: '#eab308', label: '해안포/장사정포' },
mlrs: { icon: '💥', color: '#f59e0b', label: '방사포(MLRS)' },
},
coastGuard: {
hq: { icon: '🏢', color: '#3b82f6', label: '본청' },
regional: { icon: '🏢', color: '#60a5fa', label: '지방청' },
station: { icon: '🚔', color: '#22d3ee', label: '해양경찰서' },
substation: { icon: '🏠', color: '#94a3b8', label: '파출소' },
vts: { icon: '📡', color: '#f59e0b', label: 'VTS센터' },
navy: { icon: '⚓', color: '#3b82f6', label: '해군부대' },
},
airport: {
international: { icon: '✈️', color: '#a78bfa', label: '국제공항' },
domestic: { icon: '🛩️', color: '#60a5fa', label: '국내공항' },
military: { icon: '✈️', color: '#ef4444', label: '군용비행장' },
},
navWarning: {
danger: { icon: '⚠️', color: '#ef4444', label: '위험' },
caution: { icon: '⚠️', color: '#eab308', label: '주의' },
info: { icon: '', color: '#3b82f6', label: '정보' },
},
piracy: {
critical: { icon: '☠️', color: '#ef4444', label: '극고위험' },
high: { icon: '☠️', color: '#f97316', label: '고위험' },
moderate: { icon: '☠️', color: '#eab308', label: '주의' },
},
};
const KIND_DEFAULT: Record<string, { icon: string; color: string; label: string }> = {
port: { icon: '⚓', color: '#3b82f6', label: '항구' },
windFarm: { icon: '🌀', color: '#00bcd4', label: '풍력단지' },
militaryBase: { icon: '🪖', color: '#ef4444', label: '군사시설' },
govBuilding: { icon: '🏛️', color: '#f59e0b', label: '정부기관' },
nkLaunch: { icon: '🚀', color: '#dc2626', label: '북한 발사장' },
nkMissile: { icon: '💣', color: '#ef4444', label: '미사일 낙하' },
coastGuard: { icon: '🚔', color: '#4dabf7', label: '해양경찰' },
airport: { icon: '✈️', color: '#a78bfa', label: '공항' },
navWarning: { icon: '⚠️', color: '#eab308', label: '항행경보' },
piracy: { icon: '☠️', color: '#ef4444', label: '해적위험' },
infra: { icon: '⚡', color: '#ffeb3b', label: '발전/변전' },
hazard: { icon: '⚠️', color: '#ef4444', label: '위험시설' },
cnFacility: { icon: '📍', color: '#ef4444', label: '중국시설' },
jpFacility: { icon: '📍', color: '#f472b6', label: '일본시설' },
};
// subType 키 결정
const subKey = obj.type ?? obj.subType ?? obj.level ?? '';
const subGroup = kind === 'cnFacility' || kind === 'jpFacility' ? 'overseas' : kind;
const meta = SUB_META[subGroup]?.[subKey] ?? KIND_DEFAULT[kind] ?? { icon: '📍', color: '#64748b', label: kind };
// 국가 플래그
const COUNTRY_FLAG: Record<string, string> = { CN: '🇨🇳', JP: '🇯🇵', KP: '🇰🇵', KR: '🇰🇷', TW: '🇹🇼' };
const flag = kind === 'cnFacility' ? '🇨🇳' : kind === 'jpFacility' ? '🇯🇵' : COUNTRY_FLAG[obj.country] ?? '';
const countryName = kind === 'cnFacility' ? '중국' : kind === 'jpFacility' ? '일본'
: { CN: '중국', JP: '일본', KP: '북한', KR: '한국', TW: '대만' }[obj.country] ?? '';
// 이름 결정
const title = obj.nameKo || obj.name || obj.launchNameKo || obj.title || kind;
return (
<Popup longitude={lng} latitude={lat} anchor="bottom"
onClose={() => setStaticPickInfo(null)}
closeOnClick={false}
style={{ maxWidth: 280 }}
onClose={() => setStaticPickInfo(null)} closeOnClick={false}
maxWidth="280px" className="gl-popup"
>
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#333', padding: 4 }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>
{obj.nameKo || obj.name || obj.launchNameKo || obj.type || staticPickInfo.kind}
<div className="popup-body-sm" style={{ minWidth: 200 }}>
{/* 컬러 헤더 */}
<div className="popup-header" style={{ background: meta.color, color: '#000', gap: 6, padding: '4px 8px' }}>
<span>{meta.icon}</span> {title}
</div>
{/* 배지 행 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{
background: meta.color, color: '#000',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 700,
}}>
{meta.label}
</span>
{flag && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{flag} {countryName}
</span>
)}
{kind === 'hazard' && (
<span style={{
background: 'rgba(239,68,68,0.15)', color: '#ef4444',
padding: '1px 6px', borderRadius: 3, fontSize: 10, fontWeight: 600,
border: '1px solid rgba(239,68,68,0.3)',
}}> </span>
)}
{kind === 'port' && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}>
{obj.type === 'major' ? '주요항' : '중소항'}
</span>
)}
{kind === 'airport' && obj.intl && (
<span style={{ background: '#333', color: '#ccc', padding: '1px 6px', borderRadius: 3, fontSize: 10 }}></span>
)}
</div>
{/* 설명 */}
{obj.description && (
<div style={{ fontSize: 10, color: '#999', marginBottom: 4, lineHeight: 1.5 }}>{obj.description}</div>
)}
{obj.detail && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.detail}</div>
)}
{obj.note && (
<div style={{ fontSize: 10, color: '#888', marginBottom: 4, lineHeight: 1.4 }}>{obj.note}</div>
)}
{/* 필드 그리드 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{obj.operator && <div><span className="popup-label">: </span>{obj.operator}</div>}
{obj.capacity && <div><span className="popup-label">: </span><strong>{obj.capacity}</strong></div>}
{obj.output && <div><span className="popup-label">: </span><strong>{obj.output}</strong></div>}
{obj.source && <div><span className="popup-label">: </span>{obj.source}</div>}
{obj.capacityMW && <div><span className="popup-label">: </span><strong>{obj.capacityMW}MW</strong></div>}
{obj.turbines && <div><span className="popup-label">: </span>{obj.turbines}</div>}
{obj.status && <div><span className="popup-label">: </span>{obj.status}</div>}
{obj.year && <div><span className="popup-label">: </span>{obj.year}</div>}
{obj.region && <div><span className="popup-label">: </span>{obj.region}</div>}
{obj.org && <div><span className="popup-label">: </span>{obj.org}</div>}
{obj.area && <div><span className="popup-label">: </span>{obj.area}</div>}
{obj.altitude && <div><span className="popup-label">: </span>{obj.altitude}</div>}
{obj.address && <div><span className="popup-label">: </span>{obj.address}</div>}
{obj.recentUse && <div><span className="popup-label"> : </span>{obj.recentUse}</div>}
{obj.recentIncidents != null && <div><span className="popup-label"> 1: </span><strong>{obj.recentIncidents}</strong></div>}
{obj.icao && <div><span className="popup-label">ICAO: </span>{obj.icao}</div>}
{kind === 'nkMissile' && (
<>
{obj.typeKo && <div><span className="popup-label">: </span>{obj.typeKo}</div>}
{obj.date && <div><span className="popup-label">: </span>{obj.date} {obj.time}</div>}
{obj.distanceKm && <div><span className="popup-label">: </span>{obj.distanceKm}km</div>}
{obj.altitudeKm && <div><span className="popup-label">: </span>{obj.altitudeKm}km</div>}
{obj.flightMin && <div><span className="popup-label">: </span>{obj.flightMin}</div>}
{obj.launchNameKo && <div><span className="popup-label">: </span>{obj.launchNameKo}</div>}
</>
)}
{obj.name && obj.nameKo && obj.name !== obj.nameKo && (
<div><span className="popup-label">: </span>{obj.name}</div>
)}
<div style={{ fontSize: 9, color: '#666', marginTop: 2 }}>
{lat.toFixed(4)}°N, {lng.toFixed(4)}°E
</div>
</div>
{obj.description && <div style={{ fontSize: 10, color: '#666' }}>{obj.description}</div>}
{obj.date && <div style={{ fontSize: 10 }}>: {obj.date} {obj.time || ''}</div>}
{obj.missileType && <div style={{ fontSize: 10 }}>: {obj.missileType}</div>}
{obj.range && <div style={{ fontSize: 10 }}>: {obj.range}</div>}
{obj.operator && <div style={{ fontSize: 10 }}>: {obj.operator}</div>}
{obj.capacity && <div style={{ fontSize: 10 }}>: {obj.capacity}</div>}
</div>
</Popup>
);

파일 보기

@ -12,7 +12,10 @@ interface Props {
*/
export function DeckGLOverlay({ layers }: Props) {
const overlay = useControl<MapboxOverlay>(
() => new MapboxOverlay({ interleaved: true }),
() => new MapboxOverlay({
interleaved: true,
getCursor: ({ isHovering }) => isHovering ? 'pointer' : '',
}),
);
overlay.setProps({ layers });
return null;

파일 보기

@ -526,16 +526,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
return (
<>
<Source id="ships-source" type="geojson" data={shipGeoJson} promoteId="mmsi">
{/* Hovered ship highlight ring */}
{/* Hovered ship highlight ring — feature-state는 paint에서만 지원 */}
<Layer
id="ships-hover-ring"
type="circle"
filter={['boolean', ['feature-state', 'hovered'], false]}
paint={{
'circle-radius': 18,
'circle-radius': ['case', ['boolean', ['feature-state', 'hovered'], false], 18, 0],
'circle-color': 'rgba(255, 255, 255, 0.1)',
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 2,
'circle-stroke-width': ['case', ['boolean', ['feature-state', 'hovered'], false], 2, 0],
'circle-stroke-opacity': 0.9,
}}
/>
@ -560,7 +559,7 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
layout={{
'visibility': highlightKorean ? 'visible' : 'none',
'text-field': ['get', 'name'],
'text-size': 9,
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 8, 6, 9, 8, 11, 10, 14, 12, 16, 13, 18, 14, 20],
'text-offset': [0, 2.2],
'text-anchor': 'top',
'text-allow-overlap': false,
@ -578,7 +577,15 @@ export function ShipLayer({ ships, militaryOnly, koreanOnly, hoveredMmsi, focusM
type="symbol"
layout={{
'icon-image': 'ship-triangle',
'icon-size': ['get', 'size'],
'icon-size': ['interpolate', ['linear'], ['zoom'],
4, ['*', ['get', 'size'], 0.8],
6, ['*', ['get', 'size'], 1.0],
8, ['*', ['get', 'size'], 1.5],
10, ['*', ['get', 'size'], 2.2],
12, ['*', ['get', 'size'], 2.8],
13, ['*', ['get', 'size'], 3.5],
14, ['*', ['get', 'size'], 4.2],
],
'icon-rotate': ['get', 'heading'],
'icon-rotation-alignment': 'map',
'icon-allow-overlap': true,

파일 보기

@ -0,0 +1,141 @@
export interface CnFacility {
id: string;
name: string;
subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army' | 'shipyard';
lat: number;
lng: number;
operator?: string;
description?: string;
}
export const CN_POWER_PLANTS: CnFacility[] = [
{
id: 'cn-npp-hongyanhe',
name: '홍옌허(红沿河) 핵발전소',
subType: 'nuclear',
lat: 40.87,
lng: 121.02,
operator: '中国大唐集团',
description: '가압경수로 6기, 라오닝성 — 한반도 최근접 핵발전소',
},
{
id: 'cn-npp-tianwan',
name: '톈완(田湾) 핵발전소',
subType: 'nuclear',
lat: 34.71,
lng: 119.45,
operator: '江苏核电',
description: '러시아 VVER-1000 설계, 장쑤성',
},
{
id: 'cn-npp-qinshan',
name: '진산(秦山) 핵발전소',
subType: 'nuclear',
lat: 30.44,
lng: 120.96,
operator: '中核集团',
description: '중국 최초 상업 핵발전소, 저장성',
},
{
id: 'cn-npp-ningde',
name: '닝더(宁德) 핵발전소',
subType: 'nuclear',
lat: 26.73,
lng: 120.12,
operator: '中国大唐集团',
description: '가압경수로 4기, 푸젠성',
},
{
id: 'cn-thermal-dalian',
name: '다롄 화력발전소',
subType: 'thermal',
lat: 38.85,
lng: 121.55,
operator: '大连电力',
description: '석탄화력, 라오닝성',
},
{
id: 'cn-thermal-qinhuangdao',
name: '친황다오 화력발전소',
subType: 'thermal',
lat: 39.93,
lng: 119.58,
operator: '华能国际',
description: '석탄화력 대형 기지, 허베이성',
},
{
id: 'cn-thermal-tianjin',
name: '톈진 화력발전소',
subType: 'thermal',
lat: 39.08,
lng: 117.20,
operator: '华能集团',
description: '석탄화력, 톈진시',
},
];
export const CN_MILITARY_FACILITIES: CnFacility[] = [
{
id: 'cn-mil-qingdao',
name: '칭다오 해군기지',
subType: 'naval',
lat: 36.07,
lng: 120.26,
operator: '해군 북부전구',
description: '항모전단 모항, 핵잠수함 기지',
},
{
id: 'cn-mil-lushun',
name: '뤼순(旅順) 해군기지',
subType: 'naval',
lat: 38.85,
lng: 121.24,
operator: '해군 북부전구',
description: '잠수함·구축함 기지, 보하이만 입구',
},
{
id: 'cn-mil-dalian-shipyard',
name: '다롄 조선소 (항모건조)',
subType: 'shipyard',
lat: 38.92,
lng: 121.62,
operator: '中国船舶重工',
description: '랴오닝함·산둥함 건조, 항모 4번함 건조 중',
},
{
id: 'cn-mil-shenyang-cmd',
name: '북부전구 사령부',
subType: 'army',
lat: 41.80,
lng: 123.42,
operator: '해방군 북부전구',
description: '한반도·동북아 담당, 선양시',
},
{
id: 'cn-mil-shenyang-air',
name: '선양 공군기지',
subType: 'airbase',
lat: 41.77,
lng: 123.49,
operator: '공군 북부전구',
description: 'J-16 전투기 배치, 북부전구 핵심기지',
},
{
id: 'cn-mil-dandong',
name: '단둥 군사시설',
subType: 'army',
lat: 40.13,
lng: 124.38,
operator: '해방군 육군',
description: '북중 접경 전진기지, 한반도 작전 담당',
},
{
id: 'cn-mil-zhoushan',
name: '저우산 해군기지',
subType: 'naval',
lat: 30.00,
lng: 122.10,
operator: '해군 동부전구',
description: '동중국해 주력 함대 기지',
},
];

파일 보기

@ -0,0 +1,520 @@
export type HazardType = 'petrochemical' | 'lng' | 'oilTank' | 'hazardPort' | 'nuclear' | 'thermal' | 'shipyard' | 'wastewater' | 'heavyIndustry';
export interface HazardFacility {
id: string;
type: HazardType;
nameKo: string;
name: string;
lat: number;
lng: number;
address?: string;
capacity?: string;
operator?: string;
description: string;
}
export const HAZARD_FACILITIES: HazardFacility[] = [
// ── 해안인접석유화학단지 ──────────────────────────────────────────
{
id: 'pc-01', type: 'petrochemical',
nameKo: '여수국가산업단지', name: 'Yeosu National Industrial Complex',
lat: 34.757, lng: 127.723,
address: '전남 여수시 화치동 산 183-1',
capacity: '연산 2,400만 톤', operator: '여수광양항만공사·LG화학·롯데케미칼',
description: '국내 최대 석유화학단지. NCC·LG화학·롯데케미칼·GS칼텍스 등 입주.',
},
{
id: 'pc-02', type: 'petrochemical',
nameKo: '울산미포국가산업단지', name: 'Ulsan Mipo National Industrial Complex',
lat: 35.479, lng: 129.357,
address: '울산광역시 남구 사평로 137 (부곡동 439-1)',
capacity: '연산 1,800만 톤', operator: 'S-OIL·SK에너지·SK지오센트릭',
description: '정유·NCC 중심 울산미포국가산단 내 석유화학 집적지.',
},
{
id: 'pc-03', type: 'petrochemical',
nameKo: '대산석유화학단지', name: 'Daesan Petrochemical Complex',
lat: 37.025, lng: 126.360,
address: '충남 서산시 대산읍 독곶1로 82 (롯데케미칼 대산공장 기준)',
capacity: '연산 900만 톤', operator: '롯데케미칼·현대오일뱅크·한화토탈에너지스',
description: '충남 서산 대산항 인근 3대 석유화학단지.',
},
{
id: 'pc-04', type: 'petrochemical',
nameKo: '광양 석유화학단지', name: 'Gwangyang Petrochemical Complex',
lat: 34.970, lng: 127.705,
capacity: '연산 600만 톤', operator: 'POSCO·포스코케미칼',
description: '광양제철소 연계 석유화학 시설.',
},
{
id: 'pc-05', type: 'petrochemical',
nameKo: '인천 석유화학단지', name: 'Incheon Petrochemical Complex',
lat: 37.470, lng: 126.618,
capacity: '연산 400만 톤', operator: 'SK인천석유화학',
description: '인천 북항 인근 정유·석유화학 시설.',
},
// ── LNG 생산기지 (한국가스공사 KOGAS) ────────────────────────────
{
id: 'lng-01', type: 'lng',
nameKo: '평택 LNG 생산기지', name: 'Pyeongtaek LNG Production Base',
lat: 37.017, lng: 126.870,
address: '경기도 평택시 포승읍',
operator: '한국가스공사(KOGAS)',
description: '국내 최초의 LNG 기지. 수도권 공급의 핵심 거점.',
},
{
id: 'lng-02', type: 'lng',
nameKo: '인천 LNG 생산기지', name: 'Incheon LNG Production Base',
lat: 37.374, lng: 126.622,
address: '인천광역시 연수구 송도동',
operator: '한국가스공사(KOGAS)',
description: '세계 최대 규모의 해상 LNG 기지 중 하나.',
},
{
id: 'lng-03', type: 'lng',
nameKo: '통영 LNG 생산기지', name: 'Tongyeong LNG Production Base',
lat: 34.906, lng: 128.465,
address: '경상남도 통영시 광도면',
operator: '한국가스공사(KOGAS)',
description: '남부권 가스 공급 및 영남권 산업단지 지원 거점.',
},
{
id: 'lng-04', type: 'lng',
nameKo: '삼척 LNG 생산기지', name: 'Samcheok LNG Production Base',
lat: 37.262, lng: 129.290,
address: '강원도 삼척시 원덕읍',
operator: '한국가스공사(KOGAS)',
description: '동해안 에너지 거점 및 수입 다변화 대응.',
},
{
id: 'lng-05', type: 'lng',
nameKo: '제주 LNG 생산기지', name: 'Jeju LNG Production Base',
lat: 33.448, lng: 126.330,
address: '제주특별자치도 제주시 애월읍',
operator: '한국가스공사(KOGAS)',
description: '제주 지역 천연가스 보급을 위해 조성된 기지.',
},
{
id: 'lng-06', type: 'lng',
nameKo: '당진 LNG 생산기지', name: 'Dangjin LNG Production Base',
lat: 37.048, lng: 126.595,
address: '충청남도 당진시 석문면',
operator: '한국가스공사(KOGAS)',
description: '2026년 말 1단계 준공 예정 (현재 건설 중).',
},
// ── 민간 LNG 터미널 ──────────────────────────────────────────────
{
id: 'lng-p01', type: 'lng',
nameKo: '광양 LNG 터미널', name: 'Gwangyang LNG Terminal',
lat: 34.934, lng: 127.714,
address: '전라남도 광양시 금호동',
operator: '포스코인터내셔널',
description: '포스코인터내셔널 운영 민간 LNG 터미널.',
},
{
id: 'lng-p02', type: 'lng',
nameKo: '보령 LNG 터미널', name: 'Boryeong LNG Terminal',
lat: 36.380, lng: 126.513,
address: '충청남도 보령시 오천면',
operator: 'SK E&S · GS에너지',
description: 'SK E&S·GS에너지 공동 운영 민간 LNG 터미널.',
},
{
id: 'lng-p03', type: 'lng',
nameKo: '울산 북항 에너지터미널', name: 'Ulsan North Port Energy Terminal',
lat: 35.518, lng: 129.383,
address: '울산광역시 남구 북항 일원',
operator: 'KET (한국석유공사·SK Gas 등)',
description: 'KET(Korea Energy Terminal) 운영 민간 에너지터미널.',
},
{
id: 'lng-p04', type: 'lng',
nameKo: '통영 에코파워 LNG', name: 'Tongyeong Ecopower LNG Terminal',
lat: 34.873, lng: 128.508,
address: '경상남도 통영시 광도면 (성동조선 인근)',
operator: 'HDC현대산업개발 등',
description: '성동조선 인근 민간 LNG 터미널.',
},
// ── 유류저장탱크 ──────────────────────────────────────────────────
{
id: 'oil-01', type: 'oilTank',
nameKo: '여수 유류저장시설', name: 'Yeosu Oil Storage',
lat: 34.733, lng: 127.741,
capacity: '630만 ㎘', operator: 'SK에너지·GS칼텍스',
description: '여수항 인근 정유제품 및 원유 저장시설.',
},
{
id: 'oil-02', type: 'oilTank',
nameKo: '울산 정유 저장시설', name: 'Ulsan Refinery Storage',
lat: 35.516, lng: 129.413,
capacity: '850만 ㎘', operator: 'S-OIL·SK에너지',
description: '울산 온산 정유시설 연계 대형 유류탱크군.',
},
{
id: 'oil-03', type: 'oilTank',
nameKo: '포항 저유소', name: 'Pohang Oil Depot',
lat: 36.018, lng: 129.380,
capacity: '20만 ㎘', operator: '대한송유관공사',
description: '동해안 석유 공급 거점 저유소.',
},
{
id: 'oil-04', type: 'oilTank',
nameKo: '목포 유류저장', name: 'Mokpo Oil Storage',
lat: 34.773, lng: 126.384,
capacity: '30만 ㎘', operator: '한국석유공사',
description: '서남해안 유류 공급 저장기지.',
},
{
id: 'oil-05', type: 'oilTank',
nameKo: '부산 북항 저유소', name: 'Busan North Port Oil Depot',
lat: 35.100, lng: 129.041,
capacity: '45만 ㎘', operator: '대한송유관공사',
description: '부산항 연계 유류 저장·공급 시설.',
},
{
id: 'oil-06', type: 'oilTank',
nameKo: '보령 저유소', name: 'Boryeong Oil Depot',
lat: 36.380, lng: 126.570,
capacity: '15만 ㎘', operator: '대한송유관공사',
description: '충남 서해안 유류 공급 저장기지.',
},
// ── KNOC 국가 석유비축기지 ────────────────────────────────────────
{
id: 'knoc-01', type: 'oilTank',
nameKo: 'KNOC 울산 비축기지', name: 'KNOC Ulsan SPR Base',
lat: 35.406, lng: 129.351,
address: '울산광역시 울주군 온산읍 학남리',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 원유 (지상탱크) 방식.',
},
{
id: 'knoc-02', type: 'oilTank',
nameKo: 'KNOC 여수 비축기지', name: 'KNOC Yeosu SPR Base',
lat: 34.716, lng: 127.742,
address: '전라남도 여수시 낙포동',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 원유 (지상탱크·지하공동) 방식.',
},
{
id: 'knoc-03', type: 'oilTank',
nameKo: 'KNOC 거제 비축기지', name: 'KNOC Geoje SPR Base',
lat: 34.852, lng: 128.722,
address: '경상남도 거제시 일운면 지세포리',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 원유 (지하공동) 방식.',
},
{
id: 'knoc-04', type: 'oilTank',
nameKo: 'KNOC 서산 비축기지', name: 'KNOC Seosan SPR Base',
lat: 37.018, lng: 126.374,
address: '충청남도 서산시 대산읍 대죽리',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 원유·제품 (지상탱크) 방식.',
},
{
id: 'knoc-05', type: 'oilTank',
nameKo: 'KNOC 평택 비축기지', name: 'KNOC Pyeongtaek SPR Base',
lat: 37.017, lng: 126.858,
address: '경기도 평택시 포승읍 원정리',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. LPG 방식.',
},
{
id: 'knoc-06', type: 'oilTank',
nameKo: 'KNOC 구리 비축기지', name: 'KNOC Guri SPR Base',
lat: 37.562, lng: 127.138,
address: '경기도 구리시 아차산로',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 제품 (지하공동) 방식.',
},
{
id: 'knoc-07', type: 'oilTank',
nameKo: 'KNOC 용인 비축기지', name: 'KNOC Yongin SPR Base',
lat: 37.238, lng: 127.213,
address: '경기도 용인시 처인구 해실로',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.',
},
{
id: 'knoc-08', type: 'oilTank',
nameKo: 'KNOC 동해 비축기지', name: 'KNOC Donghae SPR Base',
lat: 37.503, lng: 129.097,
address: '강원특별자치도 동해시 공단12로',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.',
},
{
id: 'knoc-09', type: 'oilTank',
nameKo: 'KNOC 곡성 비축기지', name: 'KNOC Gokseong SPR Base',
lat: 35.228, lng: 127.302,
address: '전라남도 곡성군 겸면 괴정리',
capacity: '국가비축', operator: '한국석유공사(KNOC)',
description: '국가 전략 석유비축기지. 제품 (지상탱크) 방식.',
},
// ── 위험물항만하역시설 ────────────────────────────────────────────
{
id: 'hp-01', type: 'hazardPort',
nameKo: '광양항 위험물 부두', name: 'Gwangyang Hazardous Cargo Terminal',
lat: 34.923, lng: 127.703,
capacity: '연 3,000만 톤', operator: '여수광양항만공사',
description: '석유화학제품·액체화물 전용 위험물 하역 부두.',
},
{
id: 'hp-02', type: 'hazardPort',
nameKo: '울산항 위험물 부두', name: 'Ulsan Hazardous Cargo Terminal',
lat: 35.519, lng: 129.392,
capacity: '연 2,500만 톤', operator: '울산항만공사',
description: '원유·석유제품·LPG 등 위험물 전용 하역 부두.',
},
{
id: 'hp-03', type: 'hazardPort',
nameKo: '인천항 위험물 부두', name: 'Incheon Hazardous Cargo Terminal',
lat: 37.464, lng: 126.621,
capacity: '연 800만 톤', operator: '인천항만공사',
description: '인천 북항 위험물(화학·가스·유류) 하역 전용 부두.',
},
{
id: 'hp-04', type: 'hazardPort',
nameKo: '여수항 위험물 부두', name: 'Yeosu Hazardous Cargo Terminal',
lat: 34.729, lng: 127.741,
capacity: '연 1,200만 톤', operator: '여수광양항만공사',
description: '여수 석유화학단지 연계 위험물 하역 부두.',
},
{
id: 'hp-05', type: 'hazardPort',
nameKo: '부산항 위험물 부두', name: 'Busan Hazardous Cargo Terminal',
lat: 35.090, lng: 129.022,
capacity: '연 500만 톤', operator: '부산항만공사',
description: '부산 신항·북항 위험물 전용 하역 부두.',
},
{
id: 'hp-06', type: 'hazardPort',
nameKo: '군산항 위험물 부두', name: 'Gunsan Hazardous Cargo Terminal',
lat: 35.973, lng: 126.712,
capacity: '연 300만 톤', operator: '군산항만공사',
description: '서해안 위험물(석유·화학) 하역 부두.',
},
// ── 원자력발전소 ──────────────────────────────────────────────────
{
id: 'npp-01', type: 'nuclear',
nameKo: '고리 원자력발전소', name: 'Kori Nuclear Power Plant',
lat: 35.316, lng: 129.291,
address: '부산광역시 기장군 장안읍 고리',
capacity: '4기 (신고리 포함 총 6기)', operator: '한국수력원자력(한수원)',
description: '국내 최초 상업용 원전 부지. 1호기 영구정지(2017), 신고리 1~4호기 운영 중.',
},
{
id: 'npp-02', type: 'nuclear',
nameKo: '월성 원자력발전소', name: 'Wolseong Nuclear Power Plant',
lat: 35.712, lng: 129.476,
address: '경상북도 경주시 양남면 나아리',
capacity: '4기 (월성·신월성)', operator: '한국수력원자력(한수원)',
description: '중수로(CANDU) 방식. 월성 1호기 영구정지(2019), 신월성 1·2호기 운영 중.',
},
{
id: 'npp-03', type: 'nuclear',
nameKo: '한울 원자력발전소', name: 'Hanul Nuclear Power Plant',
lat: 37.093, lng: 129.381,
address: '경상북도 울진군 북면 부구리',
capacity: '6기 운영 + 신한울 2기', operator: '한국수력원자력(한수원)',
description: '구 울진 원전. 한울 1~6호기 + 신한울 1·2호기(2022~2024 준공).',
},
{
id: 'npp-04', type: 'nuclear',
nameKo: '한빛 원자력발전소', name: 'Hanbit Nuclear Power Plant',
lat: 35.410, lng: 126.424,
address: '전라남도 영광군 홍농읍 계마리',
capacity: '6기 운영', operator: '한국수력원자력(한수원)',
description: '구 영광 원전. 한빛 1~6호기 운영 중. 국내 최대 용량 원전 부지.',
},
{
id: 'npp-05', type: 'nuclear',
nameKo: '새울 원자력발전소', name: 'Saeul Nuclear Power Plant',
lat: 35.311, lng: 129.303,
address: '울산광역시 울주군 서생면 신암리',
capacity: '4기 (신고리 5~8호기)', operator: '한국수력원자력(한수원)',
description: '신고리 5·6호기 운영 중, 7·8호기 건설 예정. 고리 부지 인근.',
},
// ── 화력발전소 ────────────────────────────────────────────────────
{
id: 'tp-01', type: 'thermal',
nameKo: '당진 화력발전소', name: 'Dangjin Thermal Power Plant',
lat: 37.048, lng: 126.598,
address: '충청남도 당진시 석문면 교로리',
capacity: '6,040MW (10기)', operator: '한국동서발전(EWP)',
description: '국내 최대 규모 석탄 화력발전소.',
},
{
id: 'tp-02', type: 'thermal',
nameKo: '태안 화력발전소', name: 'Taean Thermal Power Plant',
lat: 36.849, lng: 126.232,
address: '충청남도 태안군 원북면 방갈리',
capacity: '6,100MW (10기)', operator: '한국서부발전(WPP)',
description: '서해안 최대 규모 석탄 화력발전소.',
},
{
id: 'tp-03', type: 'thermal',
nameKo: '삼척 화력발전소', name: 'Samcheok Thermal Power Plant',
lat: 37.243, lng: 129.326,
address: '강원특별자치도 삼척시 근덕면 초곡리',
capacity: '2,100MW (2기)', operator: '삼척블루파워(포스코에너지·GS에너지)',
description: '동해안 민자 석탄 화력발전소. 2022년 준공.',
},
{
id: 'tp-04', type: 'thermal',
nameKo: '여수 화력발전소', name: 'Yeosu Thermal Power Plant',
lat: 34.738, lng: 127.721,
address: '전라남도 여수시 낙포동',
capacity: '870MW', operator: 'GS E&R',
description: '여수 석유화학단지 인근 열병합 발전소.',
},
{
id: 'tp-05', type: 'thermal',
nameKo: '하동 화력발전소', name: 'Hadong Thermal Power Plant',
lat: 34.977, lng: 127.901,
address: '경상남도 하동군 금성면 갈사리',
capacity: '4,000MW (8기)', operator: '한국남부발전(KOSPO)',
description: '남해안 주요 석탄 화력발전소.',
},
// ── 조선소 도장시설 ───────────────────────────────────────────────
{
id: 'sy-01', type: 'shipyard',
nameKo: '한화오션 거제조선소', name: 'Hanwha Ocean Geoje Shipyard',
lat: 34.893, lng: 128.623,
address: '경상남도 거제시 아주동 1',
operator: '한화오션(구 대우조선해양)',
description: '초대형 선박·해양플랜트 도장시설. 유기용제·VOC 대량 취급.',
},
{
id: 'sy-02', type: 'shipyard',
nameKo: 'HD현대중공업 울산조선소', name: 'HD Hyundai Heavy Industries Ulsan Shipyard',
lat: 35.508, lng: 129.421,
address: '울산광역시 동구 방어진순환도로 1000',
operator: 'HD현대중공업',
description: '세계 최대 단일 조선소. 도크 10기, 도장시설·VOC 취급.',
},
{
id: 'sy-03', type: 'shipyard',
nameKo: '삼성중공업 거제조선소', name: 'Samsung Heavy Industries Geoje Shipyard',
lat: 34.847, lng: 128.682,
address: '경상남도 거제시 장평동 530',
operator: '삼성중공업',
description: 'LNG 운반선·FPSO 전문 조선소. 도장·도막 처리시설.',
},
{
id: 'sy-04', type: 'shipyard',
nameKo: 'HD현대미포조선 울산', name: 'HD Hyundai Mipo Dockyard Ulsan',
lat: 35.479, lng: 129.407,
address: '울산광역시 동구 화정동',
operator: 'HD현대미포조선',
description: '중형 선박 전문 조선소. 도장시설 다수.',
},
{
id: 'sy-05', type: 'shipyard',
nameKo: 'HD현대삼호 영암조선소', name: 'HD Hyundai Samho Yeongam Shipyard',
lat: 34.746, lng: 126.459,
address: '전라남도 영암군 삼호읍 용당리',
operator: 'HD현대삼호중공업',
description: '서남해안 대형 조선소. 유기용제·도장 화학물질 취급.',
},
{
id: 'sy-06', type: 'shipyard',
nameKo: 'HJ중공업 부산조선소', name: 'HJ Shipbuilding Busan Shipyard',
lat: 35.048, lng: 128.978,
address: '부산광역시 영도구 해양로 195',
operator: 'HJ중공업(구 한진중공업)',
description: '부산 영도 소재 조선소. 도장·표면처리 시설.',
},
// ── 폐수/하수처리장 ───────────────────────────────────────────────
{
id: 'ww-01', type: 'wastewater',
nameKo: '여수 국가산단 폐수처리장', name: 'Yeosu Industrial Wastewater Treatment',
lat: 34.748, lng: 127.730,
address: '전라남도 여수시 화치동',
operator: '여수시·환경부',
description: '여수국가산단 배후 산업폐수처리장. 황화수소·메탄 발생 가능.',
},
{
id: 'ww-02', type: 'wastewater',
nameKo: '울산 온산공단 폐수처리장', name: 'Ulsan Onsan Industrial Wastewater Treatment',
lat: 35.413, lng: 129.338,
address: '울산광역시 울주군 온산읍',
operator: '울산시·환경부',
description: '온산국가산업단지 배후 폐수처리 거점. 유해가스 발생 위험.',
},
{
id: 'ww-03', type: 'wastewater',
nameKo: '대산공단 폐수처리장', name: 'Daesan Industrial Wastewater Treatment',
lat: 37.023, lng: 126.348,
address: '충청남도 서산시 대산읍',
operator: '서산시·환경부',
description: '대산석유화학단지 배후 폐수처리장. H₂S·메탄 발생 위험.',
},
{
id: 'ww-04', type: 'wastewater',
nameKo: '인천 북항 항만폐수처리', name: 'Incheon North Port Wastewater Treatment',
lat: 37.468, lng: 126.618,
address: '인천광역시 중구 북성동',
operator: '인천항만공사·인천시',
description: '인천 북항 인접 항만 폐수처리 시설.',
},
{
id: 'ww-05', type: 'wastewater',
nameKo: '광양 임해 폐수처리장', name: 'Gwangyang Coastal Wastewater Treatment',
lat: 34.930, lng: 127.696,
address: '전라남도 광양시 금호동',
operator: '광양시·포스코',
description: '광양제철소·산단 배후 폐수처리 시설. 황화수소 발생 위험.',
},
// ── 시멘트/제철소/원료저장시설 ────────────────────────────────────
{
id: 'hi-01', type: 'heavyIndustry',
nameKo: 'POSCO 포항제철소', name: 'POSCO Pohang Steelworks',
lat: 36.027, lng: 129.358,
address: '경상북도 포항시 남구 동해안로 6261',
capacity: '1,800만 톤/년', operator: 'POSCO',
description: '국내 최대 제철소. 고로·코크스 원료 대량 저장·처리.',
},
{
id: 'hi-02', type: 'heavyIndustry',
nameKo: 'POSCO 광양제철소', name: 'POSCO Gwangyang Steelworks',
lat: 34.932, lng: 127.702,
address: '전라남도 광양시 금호동 700',
capacity: '2,100만 톤/년', operator: 'POSCO',
description: '세계 최대 규모 제철소 중 하나. 임해 원료 저장기지.',
},
{
id: 'hi-03', type: 'heavyIndustry',
nameKo: '현대제철 당진공장', name: 'Hyundai Steel Dangjin Plant',
lat: 37.046, lng: 126.616,
address: '충청남도 당진시 송악읍 복운리',
capacity: '1,200만 톤/년', operator: '현대제철',
description: '당진 임해 제철소. 철광석·석탄 원료저장 부두 인접.',
},
{
id: 'hi-04', type: 'heavyIndustry',
nameKo: '삼척 시멘트 공단', name: 'Samcheok Cement Industrial Complex',
lat: 37.480, lng: 129.130,
address: '강원특별자치도 삼척시 동해대로',
operator: '쌍용C&E·성신양회',
description: '삼척 임해 시멘트 단지. 분진·원료저장시설 밀집.',
},
{
id: 'hi-05', type: 'heavyIndustry',
nameKo: '동해 시멘트/석회공장', name: 'Donghae Cement Complex',
lat: 37.501, lng: 129.103,
address: '강원특별자치도 동해시 북평공단',
operator: '한일시멘트·아세아시멘트',
description: '동해항 인근 시멘트·석회 생산·원료저장시설.',
},
];

파일 보기

@ -0,0 +1,150 @@
export interface JpFacility {
id: string;
name: string;
subType: 'nuclear' | 'thermal' | 'naval' | 'airbase' | 'army';
lat: number;
lng: number;
operator?: string;
description?: string;
}
export const JP_POWER_PLANTS: JpFacility[] = [
{
id: 'jp-npp-genkai',
name: '겐카이(玄海) 핵발전소',
subType: 'nuclear',
lat: 33.52,
lng: 129.84,
operator: '규슈전력',
description: '가압경수로 4기, 사가현 — 한반도 최근접 원전',
},
{
id: 'jp-npp-sendai',
name: '센다이(川内) 핵발전소',
subType: 'nuclear',
lat: 31.84,
lng: 130.19,
operator: '규슈전력',
description: '가압경수로 2기, 가고시마현',
},
{
id: 'jp-npp-ohi',
name: '오이(大飯) 핵발전소',
subType: 'nuclear',
lat: 35.53,
lng: 135.65,
operator: '간사이전력',
description: '가압경수로 4기, 후쿠이현 — 일본 최대 출력',
},
{
id: 'jp-npp-takahama',
name: '다카하마(高浜) 핵발전소',
subType: 'nuclear',
lat: 35.51,
lng: 135.50,
operator: '간사이전력',
description: '가압경수로 4기, 후쿠이현',
},
{
id: 'jp-npp-shika',
name: '시카(志賀) 핵발전소',
subType: 'nuclear',
lat: 37.07,
lng: 136.72,
operator: '호쿠리쿠전력',
description: '비등수형경수로 2기, 이시카와현 (2024 지진 피해)',
},
{
id: 'jp-npp-higashidori',
name: '히가시도리(東通) 핵발전소',
subType: 'nuclear',
lat: 41.18,
lng: 141.37,
operator: '도호쿠전력',
description: '비등수형경수로, 아오모리현',
},
{
id: 'jp-thermal-matsuura',
name: '마쓰우라(松浦) 화력발전소',
subType: 'thermal',
lat: 33.33,
lng: 129.73,
operator: '전원개발(J-Power)',
description: '석탄화력, 나가사키현 — 대한해협 인접',
},
{
id: 'jp-thermal-hekinan',
name: '헤키난(碧南) 화력발전소',
subType: 'thermal',
lat: 34.87,
lng: 136.95,
operator: '주부전력',
description: '석탄화력, 아이치현 — 일본 최대 석탄화력',
},
];
export const JP_MILITARY_FACILITIES: JpFacility[] = [
{
id: 'jp-mil-sasebo',
name: '사세보(佐世保) 해군기지',
subType: 'naval',
lat: 33.16,
lng: 129.72,
operator: '미 해군 / 해상자위대',
description: '미 7함대 상륙전단 모항, 한국 최근접 미군기지',
},
{
id: 'jp-mil-maizuru',
name: '마이즈루(舞鶴) 해군기지',
subType: 'naval',
lat: 35.47,
lng: 135.38,
operator: '해상자위대',
description: '동해 방면 주력기지, 호위함대 사령부',
},
{
id: 'jp-mil-yokosuka',
name: '요코스카(横須賀) 해군기지',
subType: 'naval',
lat: 35.29,
lng: 139.67,
operator: '미 해군 / 해상자위대',
description: '미 7함대 사령부, 항모 로널드 레이건 모항',
},
{
id: 'jp-mil-iwakuni',
name: '이와쿠니(岩国) 공군기지',
subType: 'airbase',
lat: 34.15,
lng: 132.24,
operator: '미 해병대 / 항공자위대',
description: 'F/A-18 및 F-35B 배치, 야마구치현',
},
{
id: 'jp-mil-kadena',
name: '가데나(嘉手納) 공군기지',
subType: 'airbase',
lat: 26.36,
lng: 127.77,
operator: '미 공군',
description: 'F-15C/D, KC-135 배치, 아시아 최대 미 공군기지',
},
{
id: 'jp-mil-ashiya',
name: '아시야(芦屋) 항공기지',
subType: 'airbase',
lat: 33.88,
lng: 130.66,
operator: '항공자위대',
description: '대한해협 인접, 후쿠오카현',
},
{
id: 'jp-mil-naha',
name: '나하(那覇) 항공기지',
subType: 'airbase',
lat: 26.21,
lng: 127.65,
operator: '항공자위대',
description: 'F-15 배치, 남서항공방면대 사령부',
},
];

파일 보기

@ -25,6 +25,12 @@ import type { NavWarning, NavWarningLevel, TrainingOrg } from '../services/navWa
import { PIRACY_ZONES, PIRACY_LEVEL_COLOR } from '../services/piracy';
import type { PiracyZone } from '../services/piracy';
import type { PowerFacility } from '../services/infra';
import { HAZARD_FACILITIES } from '../data/hazardFacilities';
import type { HazardFacility, HazardType } from '../data/hazardFacilities';
import { CN_POWER_PLANTS, CN_MILITARY_FACILITIES } from '../data/cnFacilities';
import type { CnFacility } from '../data/cnFacilities';
import { JP_POWER_PLANTS, JP_MILITARY_FACILITIES } from '../data/jpFacilities';
import type { JpFacility } from '../data/jpFacilities';
// ─── Type alias to avoid 'any' in PickingInfo ───────────────────────────────
@ -39,7 +45,10 @@ export type StaticPickedObject =
| KoreanAirport
| NavWarning
| PiracyZone
| PowerFacility;
| PowerFacility
| HazardFacility
| CnFacility
| JpFacility;
export type StaticLayerKind =
| 'port'
@ -52,7 +61,10 @@ export type StaticLayerKind =
| 'airport'
| 'navWarning'
| 'piracy'
| 'infra';
| 'infra'
| 'hazard'
| 'cnFacility'
| 'jpFacility';
export interface StaticPickInfo {
kind: StaticLayerKind;
@ -72,6 +84,11 @@ interface StaticLayerConfig {
piracy: boolean;
infra: boolean;
infraFacilities: PowerFacility[];
hazardTypes: HazardType[];
cnPower: boolean;
cnMilitary: boolean;
jpPower: boolean;
jpMilitary: boolean;
onPick: (info: StaticPickInfo) => void;
sizeScale?: number; // 줌 레벨별 스케일 배율 (기본 1.0)
}
@ -866,6 +883,176 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
);
}
// ── Hazard Facilities ──────────────────────────────────────────────────
if (config.hazardTypes.length > 0) {
const hazardTypeSet = new Set(config.hazardTypes);
const hazardData = HAZARD_FACILITIES.filter(f => hazardTypeSet.has(f.type));
const HAZARD_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
petrochemical: { icon: '🏭', color: [249, 115, 22, 255] },
lng: { icon: '🔵', color: [6, 182, 212, 255] },
oilTank: { icon: '🛢️', color: [234, 179, 8, 255] },
hazardPort: { icon: '⚠️', color: [239, 68, 68, 255] },
nuclear: { icon: '☢️', color: [168, 85, 247, 255] },
thermal: { icon: '🔥', color: [100, 116, 139, 255] },
shipyard: { icon: '🚢', color: [14, 165, 233, 255] },
wastewater: { icon: '💧', color: [16, 185, 129, 255] },
heavyIndustry: { icon: '⚙️', color: [148, 163, 184, 255] },
};
if (hazardData.length > 0) {
layers.push(
new TextLayer<HazardFacility>({
id: 'static-hazard-emoji',
data: hazardData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => HAZARD_META[d.type]?.icon ?? '⚠️',
getSize: 16 * sc,
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
pickable: true,
onClick: (info: PickingInfo<HazardFacility>) => {
if (info.object) config.onPick({ kind: 'hazard', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(
new TextLayer<HazardFacility>({
id: 'static-hazard-label',
data: hazardData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.nameKo.length > 12 ? d.nameKo.slice(0, 12) + '..' : d.nameKo,
getSize: 9 * sc,
getColor: (d) => HAZARD_META[d.type]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: 'monospace',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
}
// ── CN Facilities ──────────────────────────────────────────────────────
{
const CN_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
naval: { icon: '⚓', color: [59, 130, 246, 255] },
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
army: { icon: '🪖', color: [34, 197, 94, 255] },
shipyard: { icon: '🚢', color: [148, 163, 184, 255] },
};
const cnData: CnFacility[] = [
...(config.cnPower ? CN_POWER_PLANTS : []),
...(config.cnMilitary ? CN_MILITARY_FACILITIES : []),
];
if (cnData.length > 0) {
layers.push(
new TextLayer<CnFacility>({
id: 'static-cn-emoji',
data: cnData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => CN_META[d.subType]?.icon ?? '📍',
getSize: 16 * sc,
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
pickable: true,
onClick: (info: PickingInfo<CnFacility>) => {
if (info.object) config.onPick({ kind: 'cnFacility', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(
new TextLayer<CnFacility>({
id: 'static-cn-label',
data: cnData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name,
getSize: 9 * sc,
getColor: (d) => CN_META[d.subType]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: 'monospace',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
}
// ── JP Facilities ──────────────────────────────────────────────────────
{
const JP_META: Record<string, { icon: string; color: [number, number, number, number] }> = {
nuclear: { icon: '☢️', color: [239, 68, 68, 255] },
thermal: { icon: '🔥', color: [249, 115, 22, 255] },
naval: { icon: '⚓', color: [59, 130, 246, 255] },
airbase: { icon: '✈️', color: [34, 211, 238, 255] },
army: { icon: '🪖', color: [34, 197, 94, 255] },
};
const jpData: JpFacility[] = [
...(config.jpPower ? JP_POWER_PLANTS : []),
...(config.jpMilitary ? JP_MILITARY_FACILITIES : []),
];
if (jpData.length > 0) {
layers.push(
new TextLayer<JpFacility>({
id: 'static-jp-emoji',
data: jpData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => JP_META[d.subType]?.icon ?? '📍',
getSize: 16 * sc,
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
pickable: true,
onClick: (info: PickingInfo<JpFacility>) => {
if (info.object) config.onPick({ kind: 'jpFacility', object: info.object });
return true;
},
billboard: false,
characterSet: 'auto',
}),
);
layers.push(
new TextLayer<JpFacility>({
id: 'static-jp-label',
data: jpData,
getPosition: (d) => [d.lng, d.lat],
getText: (d) => d.name,
getSize: 9 * sc,
getColor: (d) => JP_META[d.subType]?.color ?? [200, 200, 200, 200],
getTextAnchor: 'middle',
getAlignmentBaseline: 'top',
getPixelOffset: [0, 12],
fontFamily: 'monospace',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [0, 0, 0, 200],
billboard: false,
characterSet: 'auto',
}),
);
}
}
return layers;
// infraFacilities는 배열 참조가 바뀌어야 갱신
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -882,6 +1069,11 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
config.nkMissile,
config.infra,
config.infraFacilities,
config.hazardTypes,
config.cnPower,
config.cnMilitary,
config.jpPower,
config.jpMilitary,
config.onPick,
config.sizeScale,
]);
@ -889,5 +1081,6 @@ export function useStaticDeckLayers(config: StaticLayerConfig): Layer[] {
// Re-export types that KoreaMap will need for Popup rendering
export type { Port, WindFarm, MilitaryBase, GovBuilding, NKLaunchSite, NKMissileEvent, CoastGuardFacility, KoreanAirport, NavWarning, PiracyZone, PowerFacility };
export type { HazardFacility, HazardType, CnFacility, JpFacility };
// Re-export label/color helpers used in Popup rendering
export { CG_TYPE_COLOR, PORT_COUNTRY_COLOR, WIND_COLOR, NW_ORG_COLOR, PIRACY_LEVEL_COLOR, getMissileColor };

파일 보기

@ -77,7 +77,7 @@
"airports": "공항",
"sensorCharts": "센서 차트",
"oilFacilities": "유전시설",
"militaryOnly": "군용기만",
"militaryOnly": "해외시설",
"infra": "발전/변전",
"cables": "해저케이블",
"cctv": "CCTV",

파일 보기

@ -0,0 +1,146 @@
// 재난/안전뉴스 — 국가재난안전포털(safekorea.go.kr) 뉴스
// CORS 제한으로 직접 크롤링 불가 → 큐레이션된 최신 항목 + 포털 링크 제공
export interface DisasterNewsItem {
id: string;
timestamp: number;
title: string;
source: string;
category: 'typhoon' | 'flood' | 'earthquake' | 'fire' | 'sea' | 'chemical' | 'safety' | 'general';
url: string;
}
const SAFEKOREA_BASE = 'https://www.safekorea.go.kr/idsiSFK/neo/sfk/cs/sfc/dis/disasterNewsList.jsp?menuSeq=619';
const CAT_ICON: Record<DisasterNewsItem['category'], string> = {
typhoon: '🌀',
flood: '🌊',
earthquake: '⚡',
fire: '🔥',
sea: '⚓',
chemical: '☣️',
safety: '🦺',
general: '📢',
};
const CAT_COLOR: Record<DisasterNewsItem['category'], string> = {
typhoon: '#06b6d4',
flood: '#3b82f6',
earthquake: '#f59e0b',
fire: '#ef4444',
sea: '#0ea5e9',
chemical: '#a855f7',
safety: '#22c55e',
general: '#64748b',
};
export function getDisasterCatIcon(cat: DisasterNewsItem['category']) {
return CAT_ICON[cat] ?? CAT_ICON.general;
}
export function getDisasterCatColor(cat: DisasterNewsItem['category']) {
return CAT_COLOR[cat] ?? CAT_COLOR.general;
}
// ── 큐레이션된 최신 재난/안전뉴스 (2026-03-21 기준) ──────────────────
export const DISASTER_NEWS: DisasterNewsItem[] = [
{
id: 'dn-0321-01',
timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(),
title: '[행안부] 봄철 해양레저 안전 유의… 3월~5월 수상사고 집중 발생 시기',
source: '국가재난안전포털',
category: 'sea',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0321-02',
timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(),
title: '해경, 갯벌 고립사고 주의 당부… 조석표 미확인 갯벌체험 사망 증가',
source: '해양경찰청',
category: 'sea',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0320-01',
timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(),
title: '부산 강서구 화학공장 화재… 유독가스 유출, 인근 주민 대피령 (완진)',
source: '국가재난안전포털',
category: 'chemical',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0320-02',
timestamp: new Date('2026-03-20T10:00:00+09:00').getTime(),
title: '[기상청] 서해상 강풍 예비특보 발효… 최대 순간풍속 25m/s 예상',
source: '기상청',
category: 'general',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0319-01',
timestamp: new Date('2026-03-19T14:00:00+09:00').getTime(),
title: '여수 앞바다 어선 전복… 선원 5명 중 3명 구조, 2명 수색 중',
source: '국가재난안전포털',
category: 'sea',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0319-02',
timestamp: new Date('2026-03-19T09:00:00+09:00').getTime(),
title: '행안부, 봄철 산불 위기경보 "주의" 발령… 강원·경북 건조특보 지속',
source: '행정안전부',
category: 'fire',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0318-01',
timestamp: new Date('2026-03-18T11:00:00+09:00').getTime(),
title: '경주 규모 2.8 지진 발생… 인근 원전 이상 없음, 여진 주의',
source: '기상청',
category: 'earthquake',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0318-02',
timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(),
title: '울산 온산공단 배관 누출… 황화수소 소량 유출, 인근 학교 임시 휴교',
source: '국가재난안전포털',
category: 'chemical',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0317-01',
timestamp: new Date('2026-03-17T15:00:00+09:00').getTime(),
title: '포항 해상 화물선 기관실 화재… 해경 대응, 선원 전원 구조',
source: '해양경찰청',
category: 'sea',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0317-02',
timestamp: new Date('2026-03-17T10:00:00+09:00').getTime(),
title: '[소방청] 봄철 소방안전대책 시행… 주거용 소화기 무상 교체 4월까지 연장',
source: '소방청',
category: 'safety',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0316-01',
timestamp: new Date('2026-03-16T13:00:00+09:00').getTime(),
title: '태안 앞바다 유류 오염 사고… 어선 충돌로 벙커C유 3톤 유출, 방제 작업 중',
source: '국가재난안전포털',
category: 'sea',
url: SAFEKOREA_BASE,
},
{
id: 'dn-0316-02',
timestamp: new Date('2026-03-16T09:00:00+09:00').getTime(),
title: '행안부, 이란 사태 관련 국내 핵심기반시설 특별점검 실시',
source: '행정안전부',
category: 'safety',
url: SAFEKOREA_BASE,
},
];
export function getDisasterNews(): DisasterNewsItem[] {
return DISASTER_NEWS.sort((a, b) => b.timestamp - a.timestamp);
}

파일 보기

@ -243,7 +243,67 @@ function extractMELocation(text: string): { lat: number; lng: number } | null {
// ── CENTCOM 최신 게시물 (수동 업데이트 — RSS 대체) ──
// Nitter/RSSHub 모두 X.com 차단으로 사용 불가하므로 주요 CENTCOM 게시물 수동 관리
const CENTCOM_POSTS: { text: string; date: string; url: string }[] = [
// ── 3월 16일 (D+16) 최신 ──
// ── 3월 21일 (D+21) 최신 ──
{
text: 'CENTCOM: US-Iran ceasefire negotiations in Muscat enter Day 2. CENTCOM forces maintaining "minimal operations" posture pending diplomatic outcome',
date: '2026-03-21T06:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'UPDATE: Strait of Hormuz commercial traffic restored to 72% of pre-conflict levels. 23 tankers transited safely in past 24hrs under coalition escort',
date: '2026-03-21T02:00:00Z',
url: 'https://x.com/CENTCOM',
},
// ── 3월 20일 (D+20) ──
{
text: 'CENTCOM: US and Iranian delegations meet in Muscat, Oman for preliminary ceasefire talks. Omani FM Al-Busaidi mediating. No agreement yet but "atmosphere constructive"',
date: '2026-03-20T14:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'Brent crude falls to $97/barrel on ceasefire talk optimism — first time below $100 since Operation Epic Fury began',
date: '2026-03-20T08:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'Multiple senior IRGC commanders reported to have departed Iran for Russia. CENTCOM assesses Iran\'s strategic command continuity "severely degraded"',
date: '2026-03-20T04:00:00Z',
url: 'https://x.com/CENTCOM',
},
// ── 3월 19일 (D+19) ──
{
text: 'BREAKING: Iran signals readiness for "unconditional ceasefire talks" through Oman channel. CENTCOM suspends offensive air operations pending diplomatic contact',
date: '2026-03-19T18:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'CENTCOM: Strait of Hormuz now 60% restored to normal commercial traffic. Coalition minesweeping teams cleared 41 mines total since Day 1',
date: '2026-03-19T09:00:00Z',
url: 'https://x.com/CENTCOM',
},
// ── 3월 18일 (D+18) ──
{
text: 'CENTCOM: Houthi forces launched coordinated mini-submarine torpedo attack against USS Nimitz CSG in Red Sea. All 3 vessels intercepted and destroyed',
date: '2026-03-18T20:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'CENTCOM: F-22 Raptors conducted first-ever combat operations over Iranian airspace, escorting B-2s striking hardened underground sites near Qom',
date: '2026-03-18T07:00:00Z',
url: 'https://x.com/CENTCOM',
},
// ── 3월 17일 (D+17) ──
{
text: 'CENTCOM: B-2 stealth bombers and GBU-57 MOPs successfully struck the Fordow Fuel Enrichment Plant. Underground enrichment halls confirmed destroyed',
date: '2026-03-17T10:00:00Z',
url: 'https://x.com/CENTCOM',
},
{
text: 'BREAKING: Iran\'s new Supreme Leader Mojtaba Khamenei issues statement delegating "pre-authorized nuclear retaliation" to IRGC. UN Security Council convenes emergency session',
date: '2026-03-17T05:30:00Z',
url: 'https://x.com/CENTCOM',
},
// ── 3월 16일 (D+16) ──
{
text: 'CENTCOM: Isfahan military complex struck overnight by B-2 stealth bombers. 15 targets destroyed including underground command bunkers',
date: '2026-03-16T06:00:00Z',
@ -404,7 +464,132 @@ async function fetchXCentcom(): Promise<OsintItem[]> {
// ── Pinned OSINT articles (manually curated) ──
const PINNED_IRAN: OsintItem[] = [
// ── 3월 16일 최신 ──
// ── 3월 21일 최신 ──
{
id: 'pinned-kr-ceasefire-talks-0321',
timestamp: new Date('2026-03-21T10:00:00+09:00').getTime(),
title: '[속보] 미-이란, 오만 무스카트서 휴전 협상 2일차… "핵 시설 감시단 수용" 이란 내부 검토',
source: '연합뉴스',
url: 'https://www.yna.co.kr',
category: 'diplomacy',
language: 'ko',
lat: 23.58, lng: 58.40,
},
{
id: 'pinned-kr-oil-drop-0321',
timestamp: new Date('2026-03-21T08:00:00+09:00').getTime(),
title: '브렌트유 $97로 하락… 휴전 협상 기대감 반영, 한국 정유사 비축유 방출 중단 검토',
source: '매일경제',
url: 'https://www.mk.co.kr',
category: 'oil',
language: 'ko',
lat: 37.57, lng: 126.98,
},
{
id: 'pinned-kr-hormuz-72pct-0321',
timestamp: new Date('2026-03-21T06:00:00+09:00').getTime(),
title: '호르무즈 해협 통항량 72% 회복… 한국 수입 유조선 5척 오늘 무사 통과',
source: 'SBS',
url: 'https://news.sbs.co.kr',
category: 'shipping',
language: 'ko',
lat: 26.56, lng: 56.25,
},
// ── 3월 20일 ──
{
id: 'pinned-kr-muscat-talks-0320',
timestamp: new Date('2026-03-20T20:00:00+09:00').getTime(),
title: '[긴급] 미-이란 협상단 오만 무스카트 회동 확인… 오만 외무 중재, 핵 동결 조건 논의',
source: 'KBS',
url: 'https://news.kbs.co.kr',
category: 'diplomacy',
language: 'ko',
lat: 23.58, lng: 58.40,
},
{
id: 'pinned-kr-irgc-flee-0320',
timestamp: new Date('2026-03-20T14:00:00+09:00').getTime(),
title: 'IRGC 고위 사령관 다수, 러시아 망명 정황 포착… 이란 지휘체계 붕괴 우려',
source: '조선일보',
url: 'https://www.chosun.com',
category: 'military',
language: 'ko',
lat: 35.69, lng: 51.39,
},
{
id: 'pinned-kr-tanker-return-0320',
timestamp: new Date('2026-03-20T09:00:00+09:00').getTime(),
title: '한국 유조선 "광양 파이오니어호" 호르무즈 통과 성공… 30일 만에 첫 정상 귀항',
source: '해사신문',
url: 'https://www.haesanews.com',
category: 'shipping',
language: 'ko',
lat: 26.56, lng: 56.25,
},
// ── 3월 19일 ──
{
id: 'pinned-kr-iran-ceasefire-0319',
timestamp: new Date('2026-03-19T18:00:00+09:00').getTime(),
title: '[속보] 이란, 오만 채널 통해 "무조건 휴전 협상 준비" 신호… 미국 "확인 중"',
source: '연합뉴스',
url: 'https://www.yna.co.kr',
category: 'diplomacy',
language: 'ko',
lat: 35.69, lng: 51.39,
},
{
id: 'pinned-kr-ko-reserves-0319',
timestamp: new Date('2026-03-19T12:00:00+09:00').getTime(),
title: '정부 "원유 수급 숨통 트였다"… 비축유 80일분 유지·추가 방출 잠정 보류',
source: '서울경제',
url: 'https://en.sedaily.com',
category: 'oil',
language: 'ko',
lat: 37.57, lng: 126.98,
},
// ── 3월 18일 ──
{
id: 'pinned-kr-houthi-sub-0318',
timestamp: new Date('2026-03-18T22:00:00+09:00').getTime(),
title: '예멘 후티, 미 항공모함 겨냥 소형 잠수정 어뢰 공격 시도… 미 해군 3척 격침',
source: 'BBC Korea',
url: 'https://www.bbc.com/korean',
category: 'military',
language: 'ko',
lat: 14.80, lng: 42.95,
},
{
id: 'pinned-kr-f22-0318',
timestamp: new Date('2026-03-18T08:00:00+09:00').getTime(),
title: 'F-22 랩터, 이란 상공 첫 실전 투입 확인… B-2 호위하며 쿰 인근 지하시설 공격',
source: '조선일보',
url: 'https://www.chosun.com',
category: 'military',
language: 'ko',
lat: 34.64, lng: 50.88,
},
// ── 3월 17일 ──
{
id: 'pinned-kr-fordow-0317',
timestamp: new Date('2026-03-17T12:00:00+09:00').getTime(),
title: '[속보] 미군, 포르도 핵연료 농축시설 벙커버스터 공격… 지하 격납고 완파 확인',
source: '연합뉴스',
url: 'https://www.yna.co.kr',
category: 'nuclear',
language: 'ko',
lat: 34.88, lng: 49.93,
},
{
id: 'pinned-kr-nuclear-threat-0317',
timestamp: new Date('2026-03-17T07:00:00+09:00').getTime(),
title: '이란 최고지도자, IRGC에 "선제 핵 보복 권한 위임" 발표… UN 안보리 긴급 소집',
source: 'MBC',
url: 'https://imnews.imbc.com',
category: 'nuclear',
language: 'ko',
lat: 35.69, lng: 51.39,
},
// ── 3월 16일 ──
{
id: 'pinned-kr-isfahan-0316',
timestamp: new Date('2026-03-16T10:00:00+09:00').getTime(),
@ -413,8 +598,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://www.yna.co.kr',
category: 'military',
language: 'ko',
lat: 32.65,
lng: 51.67,
lat: 32.65, lng: 51.67,
},
{
id: 'pinned-kr-ceasefire-0316',
@ -424,42 +608,18 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://www.voakorea.com',
category: 'diplomacy',
language: 'ko',
lat: 35.69,
lng: 51.39,
lat: 35.69, lng: 51.39,
},
// ── 3월 15일 ──
{
id: 'pinned-kr-hormuz-派兵-0315',
id: 'pinned-kr-hormuz-파병-0315',
timestamp: new Date('2026-03-15T18:00:00+09:00').getTime(),
title: '[단독] 트럼프, 한국 등 5개국에 호르무즈 군함 파견 요구… 청해부대식 파병 논의',
source: '뉴데일리',
url: 'https://www.newdaily.co.kr',
category: 'military',
language: 'ko',
lat: 26.56,
lng: 56.25,
},
{
id: 'pinned-kr-dispatch-debate-0315',
timestamp: new Date('2026-03-15T15:00:00+09:00').getTime(),
title: '[사설] 미국의 호르무즈 파병 요청, 이란전 참전 비칠 수 있어… 신중 대응 필요',
source: '경향신문',
url: 'https://www.khan.co.kr',
category: 'diplomacy',
language: 'ko',
lat: 37.57,
lng: 126.98,
},
{
id: 'pinned-kr-turkey-nato-0315',
timestamp: new Date('2026-03-15T12:00:00+09:00').getTime(),
title: 'NATO 방공망, 튀르키예 상공서 이란 탄도미사일 3번째 요격… Article 5 논의 가속',
source: 'BBC Korea',
url: 'https://www.bbc.com/korean',
category: 'military',
language: 'ko',
lat: 37.00,
lng: 35.43,
lat: 26.56, lng: 56.25,
},
{
id: 'pinned-kr-kospi-0315',
@ -469,8 +629,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://biz.newdaily.co.kr',
category: 'oil',
language: 'ko',
lat: 37.57,
lng: 126.98,
lat: 37.57, lng: 126.98,
},
// ── 3월 14일 ──
{
@ -481,8 +640,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://www.mk.co.kr',
category: 'oil',
language: 'ko',
lat: 26.56,
lng: 56.25,
lat: 26.56, lng: 56.25,
},
{
id: 'pinned-kr-hormuz-shutdown-0314',
@ -492,8 +650,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://news.ifm.kr',
category: 'shipping',
language: 'ko',
lat: 26.56,
lng: 56.25,
lat: 26.56, lng: 56.25,
},
{
id: 'pinned-kr-tanker-0314',
@ -503,8 +660,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://www.bloomberg.com',
category: 'shipping',
language: 'ko',
lat: 26.56,
lng: 56.25,
lat: 26.56, lng: 56.25,
},
// ── 3월 13일 ──
{
@ -515,8 +671,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://www.yna.co.kr',
category: 'shipping',
language: 'ko',
lat: 26.56,
lng: 56.25,
lat: 26.56, lng: 56.25,
},
{
id: 'pinned-kr-hormuz-0313b',
@ -526,8 +681,7 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://news.kbs.co.kr',
category: 'military',
language: 'ko',
lat: 26.30,
lng: 56.50,
lat: 26.30, lng: 56.50,
},
{
id: 'pinned-kr-ship-0312',
@ -537,14 +691,88 @@ const PINNED_IRAN: OsintItem[] = [
url: 'https://news.sbs.co.kr',
category: 'shipping',
language: 'ko',
lat: 26.20,
lng: 56.60,
lat: 26.20, lng: 56.60,
},
];
// ── Pinned OSINT articles (Korea maritime/security) ──
const PINNED_KOREA: OsintItem[] = [
// ── 3월 15일 최신 ──
// ── 3월 21일 최신 ──
{
id: 'pin-kr-cn-fishing-0321',
timestamp: new Date('2026-03-21T09:00:00+09:00').getTime(),
title: '[속보] 중국어선 250척 이상 서해 EEZ 집단 침범… 해경 함정 12척 긴급 출동',
source: '연합뉴스',
url: 'https://www.yna.co.kr',
category: 'fishing',
language: 'ko',
lat: 37.20, lng: 124.80,
},
{
id: 'pin-kr-hormuz-talks-0321',
timestamp: new Date('2026-03-21T07:00:00+09:00').getTime(),
title: '정부 "이란 협상 타결 시 비축유 방출 중단"… 원유 수급 정상화 기대감',
source: '서울경제',
url: 'https://en.sedaily.com',
category: 'oil',
language: 'ko',
lat: 37.57, lng: 126.98,
},
// ── 3월 20일 ──
{
id: 'pin-kr-jmsdf-0320',
timestamp: new Date('2026-03-20T16:00:00+09:00').getTime(),
title: '한미일 공동 해상 순찰 강화… F-35B 탑재 JMSDF 함정 동해 합류',
source: '국방일보',
url: 'https://www.kookbang.com',
category: 'military',
language: 'ko',
lat: 37.50, lng: 130.00,
},
{
id: 'pin-kr-mof-38ships-0320',
timestamp: new Date('2026-03-20T11:00:00+09:00').getTime(),
title: '해양수산부, 호르무즈 인근 한국 선박 38척 안전 관리 중… 2척 귀항 성공',
source: '해사신문',
url: 'https://www.haesanews.com',
category: 'shipping',
language: 'ko',
lat: 26.56, lng: 56.25,
},
// ── 3월 19일 ──
{
id: 'pin-kr-coast-guard-crackdown-0319',
timestamp: new Date('2026-03-19T10:00:00+09:00').getTime(),
title: '해경, 서해5도 꽃게 시즌 앞두고 중국 불법어선 특별단속… 18척 나포, 350척 검문',
source: '아시아경제',
url: 'https://www.asiae.co.kr',
category: 'fishing',
language: 'ko',
lat: 37.67, lng: 125.70,
},
// ── 3월 18일 ──
{
id: 'pin-kr-nk-response-0318',
timestamp: new Date('2026-03-18T14:00:00+09:00').getTime(),
title: '북한, 이란 전황 관련 "반미 연대" 성명 발표… 군사정보 공유 가능성 주목',
source: 'KBS',
url: 'https://news.kbs.co.kr',
category: 'military',
language: 'ko',
lat: 39.00, lng: 125.75,
},
// ── 3월 17일 ──
{
id: 'pin-kr-coast-guard-seizure-0317',
timestamp: new Date('2026-03-17T09:00:00+09:00').getTime(),
title: '[단독] 해경, 올해 최대 규모 중국어선 동시 나포… 부산 해경서 20척 압류·선원 47명 조사',
source: '연합뉴스',
url: 'https://www.yna.co.kr',
category: 'fishing',
language: 'ko',
lat: 35.10, lng: 129.04,
},
// ── 3월 15일 ──
{
id: 'pin-kr-nk-missile-0315',
timestamp: new Date('2026-03-15T07:00:00+09:00').getTime(),
@ -553,8 +781,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.yna.co.kr',
category: 'military',
language: 'ko',
lat: 39.00,
lng: 127.00,
lat: 39.00, lng: 127.00,
},
{
id: 'pin-kr-nk-kimyojong-0315',
@ -564,8 +791,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://news.kbs.co.kr',
category: 'military',
language: 'ko',
lat: 39.00,
lng: 125.75,
lat: 39.00, lng: 125.75,
},
{
id: 'pin-kr-hormuz-deploy-0315',
@ -575,32 +801,9 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.newdaily.co.kr',
category: 'military',
language: 'ko',
lat: 26.56,
lng: 56.25,
},
{
id: 'pin-kr-kctu-0315',
timestamp: new Date('2026-03-15T14:00:00+09:00').getTime(),
title: '민주노총 "호르무즈 파병은 침략전쟁 참전"… 파병 반대 성명',
source: '경향신문',
url: 'https://www.khan.co.kr',
category: 'diplomacy',
language: 'ko',
lat: 37.57,
lng: 126.98,
lat: 26.56, lng: 56.25,
},
// ── 3월 14일 ──
{
id: 'pin-kr-hormuz-zero-0314',
timestamp: new Date('2026-03-14T20:00:00+09:00').getTime(),
title: '[긴급] 호르무즈 해협 통항 제로… AIS 기준 양방향 선박 이동 완전 중단',
source: 'News1',
url: 'https://www.news1.kr',
category: 'shipping',
language: 'ko',
lat: 26.56,
lng: 56.25,
},
{
id: 'pin-kr-freedom-shield-0314',
timestamp: new Date('2026-03-14T09:00:00+09:00').getTime(),
@ -609,8 +812,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://imnews.imbc.com',
category: 'military',
language: 'ko',
lat: 37.50,
lng: 127.00,
lat: 37.50, lng: 127.00,
},
{
id: 'pin-kr-hmm-0314',
@ -620,8 +822,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.haesanews.com',
category: 'shipping',
language: 'ko',
lat: 26.00,
lng: 56.00,
lat: 26.00, lng: 56.00,
},
// ── 3월 13일 ──
{
@ -632,8 +833,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://en.sedaily.com',
category: 'oil',
language: 'ko',
lat: 37.57,
lng: 126.98,
lat: 37.57, lng: 126.98,
},
{
id: 'pin-kr-coast-guard-0313',
@ -643,8 +843,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.asiae.co.kr',
category: 'maritime_traffic',
language: 'ko',
lat: 37.67,
lng: 125.70,
lat: 37.67, lng: 125.70,
},
{
id: 'pin-kr-nk-destroyer-0312',
@ -654,8 +853,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.aei.org',
category: 'military',
language: 'ko',
lat: 39.80,
lng: 127.50,
lat: 39.80, lng: 127.50,
},
{
id: 'pin-kr-oil-reserve-0312',
@ -665,19 +863,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.hankyung.com',
category: 'oil',
language: 'ko',
lat: 36.97,
lng: 126.83,
},
{
id: 'pin-kr-mof-emergency-0312',
timestamp: new Date('2026-03-12T10:00:00+09:00').getTime(),
title: '해양수산부 24시간 비상체제 가동… 호르무즈 인근 한국선박 40척 안전관리',
source: '해사신문',
url: 'https://www.haesanews.com',
category: 'shipping',
language: 'ko',
lat: 36.00,
lng: 127.00,
lat: 36.97, lng: 126.83,
},
{
id: 'pin-kr-chinese-fishing-0311',
@ -687,19 +873,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.asiaa.co.kr',
category: 'fishing',
language: 'ko',
lat: 37.67,
lng: 125.50,
},
{
id: 'pin-kr-spring-safety-0311',
timestamp: new Date('2026-03-11T08:00:00+09:00').getTime(),
title: '해수부, 봄철 해양사고 예방대책 시행… 안개 충돌사고 대비 인천항 무인순찰로봇 도입',
source: 'iFM',
url: 'https://news.ifm.kr',
category: 'maritime_traffic',
language: 'ko',
lat: 37.45,
lng: 126.60,
lat: 37.67, lng: 125.50,
},
{
id: 'pin-kr-ships-hormuz-0311',
@ -709,8 +883,7 @@ const PINNED_KOREA: OsintItem[] = [
url: 'https://www.seoul.co.kr',
category: 'shipping',
language: 'ko',
lat: 26.56,
lng: 56.25,
lat: 26.56, lng: 56.25,
},
];

파일 보기

@ -144,6 +144,16 @@ export interface LayerVisibility {
oilFacilities: boolean;
meFacilities: boolean;
militaryOnly: boolean;
overseasUS: boolean;
overseasUK: boolean;
overseasIran: boolean;
overseasUAE: boolean;
overseasSaudi: boolean;
overseasOman: boolean;
overseasQatar: boolean;
overseasKuwait: boolean;
overseasIraq: boolean;
overseasBahrain: boolean;
}
export type AppMode = 'replay' | 'live';

파일 보기

@ -113,12 +113,29 @@ class VesselStore:
for mmsi, group in df_all.groupby('mmsi'):
self._tracks[str(mmsi)] = group.reset_index(drop=True)
# last_bucket 설정 — incremental fetch 시작점
if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty:
max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max()
if hasattr(max_bucket, 'to_pydatetime'):
max_bucket = max_bucket.to_pydatetime()
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None:
max_bucket = max_bucket.replace(tzinfo=timezone.utc)
self._last_bucket = max_bucket
elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty:
max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max()
if hasattr(max_ts, 'to_pydatetime'):
max_ts = max_ts.to_pydatetime()
if isinstance(max_ts, datetime) and max_ts.tzinfo is None:
max_ts = max_ts.replace(tzinfo=timezone.utc)
self._last_bucket = max_ts
vessel_count = len(self._tracks)
point_count = sum(len(v) for v in self._tracks.values())
logger.info(
'initial load complete: %d vessels, %d total points',
'initial load complete: %d vessels, %d total points, last_bucket=%s',
vessel_count,
point_count,
self._last_bucket,
)
self.refresh_static_info()