feat: 한국 레이어 핵심 기능 통합 — 해외시설·현장분석·선단강조·버그수정 #145
@ -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,7 @@ 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 './App.css';
|
||||
|
||||
function App() {
|
||||
@ -65,6 +66,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 +100,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 +174,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 +348,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 +495,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 +601,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}
|
||||
@ -587,15 +638,42 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
{ key: 'eez', label: t('layers.eez'), color: '#3b82f6', group: '해양안전' },
|
||||
{ key: 'piracy', label: t('layers.piracy'), color: '#ef4444', group: '해양안전' },
|
||||
{ key: 'nkMissile', label: '🚀 미사일 낙하', color: '#ef4444', count: 4, group: '해양안전' },
|
||||
{ key: 'cnFishing', label: '🎣 중국어선 어구', color: '#f97316', group: '해양안전' },
|
||||
// 국가기관망
|
||||
{ key: 'infra', label: t('layers.infra'), color: '#ffc107', group: '국가기관망' },
|
||||
{ key: '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: '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: 'nkLaunch', label: '🇰🇵 발사/포병진지', color: '#dc2626', count: 19, group: '해양안전' },
|
||||
// 위험시설
|
||||
{ key: 'hazardPetrochemical', label: '해안인접석유화학단지', color: '#f97316', count: 5, group: '위험시설' },
|
||||
{ key: 'hazardLng', label: 'LNG저장기지', color: '#06b6d4', count: 10, group: '위험시설' },
|
||||
{ key: 'hazardOilTank', label: '유류저장탱크', color: '#eab308', count: 15, group: '위험시설' },
|
||||
{ key: 'hazardPort', label: '위험물항만하역시설', color: '#ef4444', count: 6, group: '위험시설' },
|
||||
// 에너지/발전시설
|
||||
{ key: 'energyNuclear', label: '원자력발전', color: '#a855f7', count: 5, group: '에너지/발전시설' },
|
||||
{ key: 'energyThermal', label: '화력발전소', color: '#64748b', count: 5, group: '에너지/발전시설' },
|
||||
// 산업공정/제조시설
|
||||
{ key: 'industryShipyard', label: '조선소 도장시설', color: '#0ea5e9', count: 6, group: '산업공정/제조시설' },
|
||||
{ key: 'industryWastewater', label: '폐수/하수처리장', color: '#10b981', count: 5, group: '산업공정/제조시설' },
|
||||
{ key: 'industryHeavy', label: '시멘트/제철소', color: '#94a3b8', count: 5, group: '산업공정/제조시설' },
|
||||
]}
|
||||
overseasItems={[
|
||||
{
|
||||
key: 'overseasChina', label: '🇨🇳 중국', color: '#ef4444',
|
||||
children: [
|
||||
{ key: 'cnPower', label: '발전소', color: '#a855f7' },
|
||||
{ key: 'cnMilitary', label: '주요군사시설', color: '#ef4444' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'overseasJapan', label: '🇯🇵 일본', color: '#f472b6',
|
||||
children: [
|
||||
{ key: 'jpPower', label: '발전소', color: '#a855f7' },
|
||||
{ key: 'jpMilitary', label: '주요군사시설', color: '#ef4444' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
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 && (
|
||||
<>
|
||||
|
||||
82
frontend/src/components/korea/CnFacilityLayer.tsx
Normal file
82
frontend/src/components/korea/CnFacilityLayer.tsx
Normal file
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
855
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
855
frontend/src/components/korea/FieldAnalysisModal.tsx
Normal file
@ -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.5–5.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}>
|
||||
|
||||
110
frontend/src/components/korea/HazardFacilityLayer.tsx
Normal file
110
frontend/src/components/korea/HazardFacilityLayer.tsx
Normal file
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/korea/JpFacilityLayer.tsx
Normal file
81
frontend/src/components/korea/JpFacilityLayer.tsx
Normal file
@ -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);
|
||||
|
||||
@ -273,6 +275,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 +370,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 +601,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 +610,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onFleetZoom={handleFleetZoom}
|
||||
onSelectedGearChange={setSelectedGearData}
|
||||
onSelectedFleetChange={setSelectedFleetData}
|
||||
/>
|
||||
)}
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && (
|
||||
@ -528,6 +629,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
illegalFishingLabelLayer,
|
||||
zoneLabelsLayer,
|
||||
...selectedGearLayers,
|
||||
...selectedFleetLayers,
|
||||
...analysisDeckLayers,
|
||||
].filter(Boolean)} />
|
||||
{/* 정적 마커 클릭 Popup */}
|
||||
@ -552,6 +654,12 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
{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>}
|
||||
{staticPickInfo.kind === 'hazard' && obj.address && (
|
||||
<div style={{ fontSize: 10, color: '#888' }}>📍 {obj.address}</div>
|
||||
)}
|
||||
{(staticPickInfo.kind === 'cnFacility' || staticPickInfo.kind === 'jpFacility') && obj.subType && (
|
||||
<div style={{ fontSize: 10, color: '#888' }}>유형: {obj.subType}</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
141
frontend/src/data/cnFacilities.ts
Normal file
141
frontend/src/data/cnFacilities.ts
Normal file
@ -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: '동중국해 주력 함대 기지',
|
||||
},
|
||||
];
|
||||
520
frontend/src/data/hazardFacilities.ts
Normal file
520
frontend/src/data/hazardFacilities.ts
Normal file
@ -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: '동해항 인근 시멘트·석회 생산·원료저장시설.',
|
||||
},
|
||||
];
|
||||
150
frontend/src/data/jpFacilities.ts
Normal file
150
frontend/src/data/jpFacilities.ts
Normal file
@ -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 * ss,
|
||||
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 * ss,
|
||||
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 * ss,
|
||||
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 * ss,
|
||||
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 * ss,
|
||||
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",
|
||||
|
||||
146
frontend/src/services/disasterNews.ts
Normal file
146
frontend/src/services/disasterNews.ts
Normal file
@ -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';
|
||||
|
||||
19
prediction/cache/vessel_store.py
vendored
19
prediction/cache/vessel_store.py
vendored
@ -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()
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user