Compare commits
27 커밋
main
...
feature/ko
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
|
|
d53eff8287 | ||
|
|
4ab7990e5d | ||
|
|
2c4535e57e | ||
|
|
8b74f455df | ||
|
|
1aa887fce4 | ||
|
|
612973e9ab | ||
|
|
468a4a2424 | ||
|
|
4edb8236f3 | ||
|
|
e4b6b1502b | ||
|
|
297d8aa56d | ||
|
|
f4ec6dd0f5 | ||
|
|
c98d6ba353 | ||
|
|
8f9dd0b546 | ||
|
|
df269bf19b | ||
|
|
a9573b020f | ||
|
|
5296e0df19 | ||
|
|
be77d97eb3 | ||
|
|
8448ea7985 | ||
|
|
0aff7302e6 | ||
|
|
409e618a39 | ||
|
|
6e37bc1f2d | ||
|
|
444b7a4a8d | ||
|
|
e18a1a4932 | ||
| 83b3d80c6d | |||
| feabf16114 | |||
|
|
5cf69a1d22 | ||
|
|
d000807909 |
@ -19,7 +19,7 @@
|
||||
<description>KCG Monitoring Dashboard Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
|
||||
@ -86,7 +86,7 @@ public class AirplanesLiveCollector {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("aircraft-init").start(() -> {
|
||||
new Thread(() -> {
|
||||
doInitialLoad("iran", IRAN_QUERIES, iranRegionBuffers);
|
||||
iranInitDone = true;
|
||||
mergePointResults("iran", iranRegionBuffers);
|
||||
@ -96,7 +96,7 @@ public class AirplanesLiveCollector {
|
||||
koreaInitDone = true;
|
||||
mergePointResults("korea", koreaRegionBuffers);
|
||||
log.info("Airplanes.live 한국 초기 로드 완료");
|
||||
});
|
||||
}, "aircraft-init").start();
|
||||
}
|
||||
|
||||
private void doInitialLoad(String region, List<RegionQuery> queries, Map<String, List<AircraftDto>> buffers) {
|
||||
|
||||
@ -58,12 +58,12 @@ public class OsintCollector {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("osint-init").start(() -> {
|
||||
new Thread(() -> {
|
||||
log.info("OSINT 초기 캐시 로드 시작");
|
||||
refreshCache("iran");
|
||||
refreshCache("korea");
|
||||
log.info("OSINT 초기 캐시 로드 완료");
|
||||
});
|
||||
}, "osint-init").start();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 10_000)
|
||||
|
||||
@ -49,11 +49,11 @@ public class SatelliteCollector {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("satellite-init").start(() -> {
|
||||
new Thread(() -> {
|
||||
log.info("위성 TLE 초기 캐시 로드 시작");
|
||||
loadCacheFromDb();
|
||||
log.info("위성 TLE 초기 캐시 로드 완료");
|
||||
});
|
||||
}, "satellite-init").start();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 600_000)
|
||||
|
||||
@ -38,10 +38,10 @@ public class PressureCollector {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("pressure-init").start(() -> {
|
||||
new Thread(() -> {
|
||||
log.info("Open-Meteo 기압 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}, "pressure-init").start();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)
|
||||
|
||||
@ -31,10 +31,10 @@ public class SeismicCollector {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("seismic-init").start(() -> {
|
||||
new Thread(() -> {
|
||||
log.info("USGS 지진 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}, "seismic-init").start();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
|
||||
|
||||
@ -24,6 +24,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import LoginPage from './components/auth/LoginPage';
|
||||
import CollectorMonitor from './components/common/CollectorMonitor';
|
||||
import { FieldAnalysisModal } from './components/korea/FieldAnalysisModal';
|
||||
import { ReportModal } from './components/korea/ReportModal';
|
||||
import { OpsGuideModal } from './components/korea/OpsGuideModal';
|
||||
import type { OpsRoute } from './components/korea/OpsGuideModal';
|
||||
import { filterFacilities } from './data/meEnergyHazardFacilities';
|
||||
// 정적 데이터 카운트용 import (이미 useStaticDeckLayers에서 번들 포함됨)
|
||||
import { EAST_ASIA_PORTS } from './data/ports';
|
||||
import { KOREAN_AIRPORTS } from './services/airports';
|
||||
@ -83,7 +87,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
meFacilities: true,
|
||||
militaryOnly: false,
|
||||
overseasUS: false,
|
||||
overseasUK: false,
|
||||
overseasIsrael: false,
|
||||
overseasIran: false,
|
||||
overseasUAE: false,
|
||||
overseasSaudi: false,
|
||||
@ -191,6 +195,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
|
||||
const [showCollectorMonitor, setShowCollectorMonitor] = useState(false);
|
||||
const [showFieldAnalysis, setShowFieldAnalysis] = useState(false);
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
const [showOpsGuide, setShowOpsGuide] = useState(false);
|
||||
const [opsRoute, setOpsRoute] = useState<OpsRoute | null>(null);
|
||||
const [timeZone, setTimeZone] = useState<'KST' | 'UTC'>('KST');
|
||||
const [hoveredShipMmsi, setHoveredShipMmsi] = useState<string | null>(null);
|
||||
const [focusShipMmsi, setFocusShipMmsi] = useState<string | null>(null);
|
||||
@ -374,6 +381,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<span className="text-[11px]">📊</span>
|
||||
현장분석
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mode-btn ${showOpsGuide ? 'active' : ''}`}
|
||||
onClick={() => setShowOpsGuide(v => !v)}
|
||||
title="경비함정 작전 가이드"
|
||||
>
|
||||
<span className="text-[11px]">⚓</span>
|
||||
작전가이드
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -512,18 +528,45 @@ 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' },
|
||||
]}
|
||||
overseasItems={(() => {
|
||||
const fc = (ck: string, st?: string) => filterFacilities(ck, st as never).length;
|
||||
const energyChildren = (ck: string) => [
|
||||
{ key: `${ck}Power`, label: '발전소', color: '#a855f7', count: fc(ck, 'power') },
|
||||
{ key: `${ck}Wind`, label: '풍력단지', color: '#22d3ee', count: fc(ck, 'wind') },
|
||||
{ key: `${ck}Nuclear`, label: '원자력발전소', color: '#f59e0b', count: fc(ck, 'nuclear') },
|
||||
{ key: `${ck}Thermal`, label: '화력발전소', color: '#64748b', count: fc(ck, 'thermal') },
|
||||
];
|
||||
const hazardChildren = (ck: string) => [
|
||||
{ key: `${ck}Petrochem`, label: '석유화학단지', color: '#f97316', count: fc(ck, 'petrochem') },
|
||||
{ key: `${ck}Lng`, label: 'LNG저장기지', color: '#0ea5e9', count: fc(ck, 'lng') },
|
||||
{ key: `${ck}OilTank`, label: '유류저장탱크', color: '#eab308', count: fc(ck, 'oil_tank') },
|
||||
{ key: `${ck}HazPort`, label: '위험물항만하역시설', color: '#dc2626', count: fc(ck, 'haz_port') },
|
||||
];
|
||||
const fullCountry = (key: string, label: string, color: string, ck: string) => ({
|
||||
key, label, color, children: [
|
||||
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', children: energyChildren(ck) },
|
||||
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', children: hazardChildren(ck) },
|
||||
],
|
||||
});
|
||||
const compactCountry = (key: string, label: string, color: string, ck: string) => ({
|
||||
key, label, color, children: [
|
||||
{ key: `${ck}Energy`, label: '에너지/발전시설', color: '#a855f7', count: filterFacilities(ck).filter(f => f.category === 'energy').length },
|
||||
{ key: `${ck}Hazard`, label: '위험시설', color: '#ef4444', count: filterFacilities(ck).filter(f => f.category === 'hazard').length },
|
||||
],
|
||||
});
|
||||
return [
|
||||
fullCountry('overseasUS', '🇺🇸 미국', '#3b82f6', 'us'),
|
||||
fullCountry('overseasIsrael', '🇮🇱 이스라엘', '#0ea5e9', 'il'),
|
||||
fullCountry('overseasIran', '🇮🇷 이란', '#22c55e', 'ir'),
|
||||
fullCountry('overseasUAE', '🇦🇪 UAE', '#f59e0b', 'ae'),
|
||||
fullCountry('overseasSaudi', '🇸🇦 사우디아라비아', '#84cc16', 'sa'),
|
||||
compactCountry('overseasOman', '🇴🇲 오만', '#e11d48', 'om'),
|
||||
compactCountry('overseasQatar', '🇶🇦 카타르', '#8b5cf6', 'qa'),
|
||||
compactCountry('overseasKuwait', '🇰🇼 쿠웨이트', '#f97316', 'kw'),
|
||||
compactCountry('overseasIraq', '🇮🇶 이라크', '#65a30d', 'iq'),
|
||||
compactCountry('overseasBahrain', '🇧🇭 바레인', '#e11d48', 'bh'),
|
||||
];
|
||||
})()}
|
||||
hiddenAcCategories={hiddenAcCategories}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
onAcCategoryToggle={toggleAcCategory}
|
||||
@ -619,7 +662,18 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
<main className="app-main">
|
||||
<div className="map-panel">
|
||||
{showFieldAnalysis && (
|
||||
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} />
|
||||
<FieldAnalysisModal ships={koreaData.ships} vesselAnalysis={vesselAnalysis} onClose={() => setShowFieldAnalysis(false)} onReport={() => setShowReport(true)} />
|
||||
)}
|
||||
{showReport && (
|
||||
<ReportModal ships={koreaData.ships} onClose={() => setShowReport(false)} />
|
||||
)}
|
||||
{showOpsGuide && (
|
||||
<OpsGuideModal
|
||||
ships={koreaData.ships}
|
||||
onClose={() => { setShowOpsGuide(false); setOpsRoute(null); }}
|
||||
onFlyTo={(lat, lng, zoom) => setFlyToTarget({ lat, lng, zoom })}
|
||||
onRouteSelect={setOpsRoute}
|
||||
/>
|
||||
)}
|
||||
<KoreaMap
|
||||
ships={koreaFiltersResult.filteredShips}
|
||||
@ -635,6 +689,9 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
dokdoWatchSuspects={koreaFiltersResult.dokdoWatchSuspects}
|
||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
externalFlyTo={flyToTarget}
|
||||
onExternalFlyToDone={() => setFlyToTarget(null)}
|
||||
opsRoute={opsRoute}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
|
||||
@ -6,6 +6,7 @@ import { getDisasterNews, getDisasterCatIcon, getDisasterCatColor } from '../../
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
import { AiChatPanel } from '../korea/AiChatPanel';
|
||||
|
||||
type DashboardTab = 'iran' | 'korea';
|
||||
|
||||
@ -349,7 +350,7 @@ function useTimeAgo() {
|
||||
export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, koreanShips, koreanShipsByCategory: _koreanShipsByCategory, chineseShips = [], osintFeed = EMPTY_OSINT, isLive = false, dashboardTab = 'iran', onTabChange: _onTabChange, ships = EMPTY_SHIPS, highlightKoreanShips = false, onToggleHighlightKorean, onShipHover, onShipClick }: Props) {
|
||||
const { t } = useTranslation(['common', 'events', 'ships']);
|
||||
const timeAgo = useTimeAgo();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships']));
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set(['kr-ships', 'cn-ships', 'cn-fishing']));
|
||||
const toggleCollapse = useCallback((key: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
@ -633,12 +634,12 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, minWidth: 60, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 8, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 8, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -688,12 +689,12 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
borderLeft: `3px solid ${mtColor}`,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: mtColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, minWidth: 70, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 9, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, minWidth: 60, color: mtColor }}>{mtLabel}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--kcg-text)' }}>
|
||||
{list.length}<span style={{ fontSize: 8, fontWeight: 400, color: 'var(--kcg-muted)' }}>{t('common:units.vessels')}</span>
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 9, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#22c55e' }}>{t('ships:status.underway')} {moving}</span>
|
||||
<span style={{ fontSize: 8, color: '#ef4444' }}>{t('ships:status.anchored')} {anchored}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -783,7 +784,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
</a>
|
||||
</div>
|
||||
{!collapsed.has('disaster-news') && (
|
||||
<div className="osint-list" style={{ maxHeight: 310, overflowY: 'auto' }}>
|
||||
<div className="osint-list" style={{ maxHeight: 110, overflowY: 'auto' }}>
|
||||
{disasterItems.map(item => {
|
||||
const icon = getDisasterCatIcon(item.category);
|
||||
const color = getDisasterCatColor(item.category);
|
||||
@ -832,7 +833,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
})()}</span>
|
||||
</div>
|
||||
{!collapsed.has('osint-korea') && (
|
||||
<div className="osint-list">
|
||||
<div className="osint-list" style={{ maxHeight: 165, overflowY: 'auto' }}>
|
||||
{(() => {
|
||||
const filtered = osintFeed.filter(item => item.category !== 'general' && item.category !== 'oil');
|
||||
const seen = new Set<string>();
|
||||
@ -879,6 +880,16 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI 해양분석 챗 */}
|
||||
{isLive && dashboardTab === 'korea' && (
|
||||
<AiChatPanel
|
||||
ships={ships}
|
||||
koreanShipCount={_koreanShipsByCategory ? Object.values(_koreanShipsByCategory).reduce((a, b) => a + b, 0) : 0}
|
||||
chineseShipCount={chineseShips?.length ?? 0}
|
||||
totalShipCount={_totalShipCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -129,6 +129,11 @@ interface OverseasItem {
|
||||
children?: OverseasItem[];
|
||||
}
|
||||
|
||||
function countOverseasLeaves(item: OverseasItem): number {
|
||||
if (!item.children?.length) return item.count ?? 0;
|
||||
return item.children.reduce((sum, c) => sum + countOverseasLeaves(c), 0);
|
||||
}
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: Record<string, boolean>;
|
||||
onToggle: (key: string) => void;
|
||||
@ -574,14 +579,34 @@ export function LayerPanel({
|
||||
{item.children?.length && expanded.has(`overseas-${item.key}`) && (
|
||||
<div className="layer-tree-children">
|
||||
{item.children.map(child => (
|
||||
<LayerTreeItem
|
||||
key={child.key}
|
||||
layerKey={child.key}
|
||||
label={child.label}
|
||||
color={child.color}
|
||||
active={layers[child.key] ?? false}
|
||||
onToggle={() => onToggle(child.key)}
|
||||
/>
|
||||
<div key={child.key}>
|
||||
<LayerTreeItem
|
||||
layerKey={child.key}
|
||||
label={child.label}
|
||||
color={child.color}
|
||||
count={child.count ?? countOverseasLeaves(child)}
|
||||
active={layers[child.key] ?? false}
|
||||
expandable={!!child.children?.length}
|
||||
isExpanded={expanded.has(`overseas-${child.key}`)}
|
||||
onToggle={() => onToggle(child.key)}
|
||||
onExpand={child.children?.length ? () => toggleExpand(`overseas-${child.key}`) : undefined}
|
||||
/>
|
||||
{child.children?.length && expanded.has(`overseas-${child.key}`) && (
|
||||
<div className="layer-tree-children">
|
||||
{child.children.map(gc => (
|
||||
<LayerTreeItem
|
||||
key={gc.key}
|
||||
layerKey={gc.key}
|
||||
label={gc.label}
|
||||
color={gc.color}
|
||||
count={gc.count}
|
||||
active={layers[gc.key] ?? false}
|
||||
onToggle={() => onToggle(gc.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
199
frontend/src/components/iran/MEEnergyHazardLayer.tsx
Normal file
199
frontend/src/components/iran/MEEnergyHazardLayer.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
ME_ENERGY_HAZARD_FACILITIES,
|
||||
SUB_TYPE_META,
|
||||
layerKeyToSubType,
|
||||
layerKeyToCountry,
|
||||
type EnergyHazardFacility,
|
||||
} from '../../data/meEnergyHazardFacilities';
|
||||
import type { FacilitySubType } from '../../data/meEnergyHazardFacilities';
|
||||
|
||||
function WindTurbineIcon({ size = 18, color = '#0891b2' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" fill="none">
|
||||
<path d="M15 14 L14.2 29 L17.8 29 L17 14 Z" fill={color} opacity="0.7" />
|
||||
<rect x="11" y="28.5" width="10" height="2" rx="1" fill={color} opacity="0.5" />
|
||||
<ellipse cx="16" cy="12" rx="2.5" ry="1.5" fill={color} />
|
||||
<circle cx="16" cy="12" r="1.2" fill="#fff" opacity="0.9" />
|
||||
<circle cx="16" cy="12" r="0.6" fill={color} />
|
||||
<path d="M16 12 L15 1.5 Q16 0.5 17 1.5 Z" fill={color} opacity="0.85" />
|
||||
<path d="M16 12 L24.5 18 Q24 19.5 22.5 18.5 Z" fill={color} opacity="0.85" />
|
||||
<path d="M16 12 L7.5 18 Q8 19.5 9.5 18.5 Z" fill={color} opacity="0.85" />
|
||||
<path d="M3 30 Q5.5 28 8 30 Q10.5 32 13 30" stroke={color} strokeWidth="0.8" fill="none" opacity="0.4" />
|
||||
<path d="M19 30 Q21.5 28 24 30 Q26.5 32 29 30" stroke={color} strokeWidth="0.8" fill="none" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FacilityIcon({ subType, color, size = 18 }: { subType: FacilitySubType; color: string; size?: number }) {
|
||||
const s = size;
|
||||
switch (subType) {
|
||||
case 'power':
|
||||
return <span style={{ fontSize: s }}>⚡</span>;
|
||||
case 'nuclear':
|
||||
return <span style={{ fontSize: s }}>☢️</span>;
|
||||
case 'thermal':
|
||||
return <span style={{ fontSize: s }}>🏭</span>;
|
||||
case 'petrochem': // Petrochemical - oil drum with pipe
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="6" rx="7" ry="3" fill={color} opacity="0.5" />
|
||||
<rect x="5" y="6" width="14" height="14" rx="1" fill={color} opacity="0.6" />
|
||||
<ellipse cx="12" cy="20" rx="7" ry="3" fill={color} opacity="0.5" />
|
||||
<line x1="8" y1="6" x2="8" y2="20" stroke={color} strokeWidth="0.8" opacity="0.8" />
|
||||
<line x1="16" y1="6" x2="16" y2="20" stroke={color} strokeWidth="0.8" opacity="0.8" />
|
||||
<path d="M12 3 L12 1" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="0.5" r="0.5" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
case 'lng': // LNG - snowflake/cold tank
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="13" r="8" fill={color} opacity="0.2" stroke={color} strokeWidth="1" />
|
||||
<line x1="12" y1="5" x2="12" y2="21" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="4" y1="13" x2="20" y2="13" stroke={color} strokeWidth="1.5" />
|
||||
<line x1="6.3" y1="7.3" x2="17.7" y2="18.7" stroke={color} strokeWidth="1.2" />
|
||||
<line x1="17.7" y1="7.3" x2="6.3" y2="18.7" stroke={color} strokeWidth="1.2" />
|
||||
<circle cx="12" cy="13" r="2" fill={color} opacity="0.6" />
|
||||
</svg>
|
||||
);
|
||||
case 'oil_tank': // Oil tank - cylinder
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
|
||||
<ellipse cx="12" cy="7" rx="8" ry="4" fill={color} opacity="0.6" />
|
||||
<rect x="4" y="7" width="16" height="12" fill={color} opacity="0.4" />
|
||||
<ellipse cx="12" cy="19" rx="8" ry="4" fill={color} opacity="0.6" />
|
||||
<path d="M4 7 v12" stroke={color} strokeWidth="1" />
|
||||
<path d="M20 7 v12" stroke={color} strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
case 'haz_port': // Hazardous port - warning triangle
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2 L22 20 H2 Z" fill={color} opacity="0.3" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<line x1="12" y1="8" x2="12" y2="14" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="12" cy="17" r="1.2" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return <span style={{ fontSize: s * 0.8 }}>📍</span>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
layers: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function MEEnergyHazardLayer({ layers }: Props) {
|
||||
const [selected, setSelected] = useState<EnergyHazardFacility | null>(null);
|
||||
|
||||
// Collect active country+subType combos from layer keys
|
||||
const visibleFacilities = useMemo(() => {
|
||||
const active = new Set<string>(); // "countryKey:subType"
|
||||
|
||||
// Also check parent energy/hazard keys (e.g. omEnergy -> show all om energy)
|
||||
const energySubTypes = ['power', 'wind', 'nuclear', 'thermal'] as const;
|
||||
const hazardSubTypes = ['petrochem', 'lng', 'oil_tank', 'haz_port'] as const;
|
||||
|
||||
for (const [key, on] of Object.entries(layers)) {
|
||||
if (!on) continue;
|
||||
const ck = layerKeyToCountry(key);
|
||||
const st = layerKeyToSubType(key);
|
||||
if (ck && st) {
|
||||
active.add(`${ck}:${st}`);
|
||||
}
|
||||
// Parent energy key (e.g. irEnergy) -> activate all energy subtypes for that country
|
||||
if (ck && key.endsWith('Energy')) {
|
||||
for (const s of energySubTypes) active.add(`${ck}:${s}`);
|
||||
}
|
||||
if (ck && key.endsWith('Hazard')) {
|
||||
for (const s of hazardSubTypes) active.add(`${ck}:${s}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (active.size === 0) return [];
|
||||
return ME_ENERGY_HAZARD_FACILITIES.filter(f =>
|
||||
active.has(`${f.countryKey}:${f.subType}`)
|
||||
);
|
||||
}, [layers]);
|
||||
|
||||
if (visibleFacilities.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleFacilities.map(f => {
|
||||
const meta = SUB_TYPE_META[f.subType];
|
||||
return (
|
||||
<Marker
|
||||
key={f.id}
|
||||
latitude={f.lat}
|
||||
longitude={f.lng}
|
||||
anchor="center"
|
||||
onClick={e => { e.originalEvent.stopPropagation(); setSelected(f); }}
|
||||
>
|
||||
<div
|
||||
title={f.nameKo}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
filter: 'drop-shadow(0 0 3px rgba(0,0,0,0.7))',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{f.subType === 'wind' ? (
|
||||
<WindTurbineIcon size={18} color={meta.color} />
|
||||
) : (
|
||||
<FacilityIcon subType={f.subType} color={meta.color} size={18} />
|
||||
)}
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
|
||||
{selected && (
|
||||
<Popup
|
||||
latitude={selected.lat}
|
||||
longitude={selected.lng}
|
||||
anchor="bottom"
|
||||
closeOnClick={false}
|
||||
onClose={() => setSelected(null)}
|
||||
maxWidth="260px"
|
||||
className="facility-popup"
|
||||
>
|
||||
<div style={{
|
||||
background: '#1a1e2e', color: '#e2e8f0', padding: '8px 10px',
|
||||
borderRadius: 6, fontSize: 11, lineHeight: 1.5,
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>
|
||||
{SUB_TYPE_META[selected.subType].icon} {selected.nameKo}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8', marginBottom: 4 }}>{selected.name}</div>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 600,
|
||||
background: SUB_TYPE_META[selected.subType].color + '30',
|
||||
color: SUB_TYPE_META[selected.subType].color,
|
||||
border: `1px solid ${SUB_TYPE_META[selected.subType].color}50`,
|
||||
}}>
|
||||
{SUB_TYPE_META[selected.subType].label}
|
||||
</span>
|
||||
{selected.capacityMW && (
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 600,
|
||||
background: 'rgba(255,255,255,0.08)', color: '#e2e8f0',
|
||||
}}>
|
||||
{selected.capacityMW.toLocaleString()} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8' }}>{selected.description}</div>
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginTop: 4 }}>
|
||||
{selected.lat.toFixed(4)}N, {selected.lng.toFixed(4)}E
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { SeismicMarker } from '../layers/SeismicMarker';
|
||||
import { OilFacilityLayer } from './OilFacilityLayer';
|
||||
import { AirportLayer } from './AirportLayer';
|
||||
import { MEFacilityLayer } from './MEFacilityLayer';
|
||||
import { MEEnergyHazardLayer } from './MEEnergyHazardLayer';
|
||||
import { iranOilFacilities } from '../../data/oilFacilities';
|
||||
import { middleEastAirports } from '../../data/airports';
|
||||
import type { GeoEvent, Aircraft, SatellitePosition, Ship, LayerVisibility } from '../../types';
|
||||
@ -273,6 +274,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
{layers.oilFacilities && <OilFacilityLayer facilities={iranOilFacilities} currentTime={currentTime} />}
|
||||
{layers.airports && <AirportLayer airports={middleEastAirports} />}
|
||||
{layers.meFacilities && <MEFacilityLayer />}
|
||||
<MEEnergyHazardLayer layers={layers} />
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
264
frontend/src/components/korea/AiChatPanel.tsx
Normal file
264
frontend/src/components/korea/AiChatPanel.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
koreanShipCount: number;
|
||||
chineseShipCount: number;
|
||||
totalShipCount: number;
|
||||
}
|
||||
|
||||
const OLLAMA_URL = '/ollama/api/chat';
|
||||
|
||||
function buildSystemPrompt(props: Props): string {
|
||||
const { ships, koreanShipCount, chineseShipCount, totalShipCount } = props;
|
||||
|
||||
// 선박 유형별 통계
|
||||
const byType: Record<string, number> = {};
|
||||
const byFlag: Record<string, number> = {};
|
||||
ships.forEach(s => {
|
||||
byType[s.category || 'unknown'] = (byType[s.category || 'unknown'] || 0) + 1;
|
||||
byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1;
|
||||
});
|
||||
|
||||
// 중국 어선 통계
|
||||
const cnFishing = ships.filter(s => s.flag === 'CN' && (s.category === 'fishing' || s.typecode === '30'));
|
||||
const cnFishingOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 5);
|
||||
|
||||
return `당신은 대한민국 해양경찰청의 해양상황 분석 AI 어시스턴트입니다.
|
||||
현재 실시간 해양 모니터링 데이터를 기반으로 분석을 제공합니다.
|
||||
|
||||
## 현재 해양 상황 요약
|
||||
- 전체 선박: ${totalShipCount}척
|
||||
- 한국 선박: ${koreanShipCount}척
|
||||
- 중국 선박: ${chineseShipCount}척
|
||||
- 중국 어선: ${cnFishing.length}척 (조업 추정: ${cnFishingOperating.length}척)
|
||||
|
||||
## 선박 유형별 현황
|
||||
${Object.entries(byType).map(([k, v]) => `- ${k}: ${v}척`).join('\n')}
|
||||
|
||||
## 국적별 현황 (상위)
|
||||
${Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([k, v]) => `- ${k}: ${v}척`).join('\n')}
|
||||
|
||||
## 한중어업협정 핵심
|
||||
- 중국 허가어선 906척 (PT 저인망 323쌍, GN 유자망 200척, PS 위망 16척, OT 1척식 13척, FC 운반선 31척)
|
||||
- 특정어업수역 I~IV에서만 조업 허가
|
||||
- 휴어기: PT/OT 4/16~10/15, GN 6/2~8/31
|
||||
- 다크베셀(AIS 차단) 감시 필수
|
||||
|
||||
## 응답 규칙
|
||||
- 한국어로 답변
|
||||
- 간결하고 분석적으로
|
||||
- 데이터 기반 답변 우선
|
||||
- 불법조업 의심 시 근거 제시
|
||||
- 필요시 조치 권고 포함`;
|
||||
}
|
||||
|
||||
export function AiChatPanel({ ships, koreanShipCount, chineseShipCount, totalShipCount }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) inputRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: input.trim(), timestamp: Date.now() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const systemPrompt = buildSystemPrompt({ ships, koreanShipCount, chineseShipCount, totalShipCount });
|
||||
const apiMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMsg.content },
|
||||
];
|
||||
|
||||
const res = await fetch(OLLAMA_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'qwen2.5:7b',
|
||||
messages: apiMessages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_predict: 1024 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Ollama error: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: data.message?.content || '응답을 생성할 수 없습니다.',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: `오류: ${err instanceof Error ? err.message : 'AI 서버 연결 실패'}. Ollama가 실행 중인지 확인하세요.`,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, ships, koreanShipCount, chineseShipCount, totalShipCount]);
|
||||
|
||||
const quickQuestions = [
|
||||
'현재 해양 상황을 요약해줘',
|
||||
'중국어선 불법조업 의심 분석해줘',
|
||||
'서해 위험도를 평가해줘',
|
||||
'다크베셀 현황 분석해줘',
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderTop: '1px solid rgba(168,85,247,0.2)',
|
||||
marginTop: 8,
|
||||
}}>
|
||||
{/* Toggle header */}
|
||||
<div
|
||||
onClick={() => setIsOpen(p => !p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 8px', cursor: 'pointer',
|
||||
background: isOpen ? 'rgba(168,85,247,0.12)' : 'rgba(168,85,247,0.04)',
|
||||
borderRadius: 4,
|
||||
borderLeft: '2px solid rgba(168,85,247,0.5)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>🤖</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#c4b5fd' }}>AI 해양분석</span>
|
||||
<span style={{ fontSize: 8, color: '#8b5cf6', marginLeft: 2, background: 'rgba(139,92,246,0.15)', padding: '1px 5px', borderRadius: 3 }}>Qwen 2.5</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 8, color: '#8b5cf6' }}>
|
||||
{isOpen ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat body */}
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: 360, background: 'rgba(88,28,135,0.08)',
|
||||
borderRadius: '0 0 6px 6px', overflow: 'hidden',
|
||||
borderLeft: '2px solid rgba(168,85,247,0.3)',
|
||||
borderBottom: '1px solid rgba(168,85,247,0.15)',
|
||||
}}>
|
||||
{/* Messages */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: 'auto', padding: '6px 8px',
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ padding: '12px 0', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, color: '#a78bfa', marginBottom: 8 }}>
|
||||
해양 상황에 대해 질문하세요
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{quickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => { setInput(q); }}
|
||||
style={{
|
||||
background: 'rgba(139,92,246,0.08)',
|
||||
border: '1px solid rgba(139,92,246,0.25)',
|
||||
borderRadius: 4, padding: '4px 8px',
|
||||
fontSize: 9, color: '#a78bfa',
|
||||
cursor: 'pointer', textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
background: msg.role === 'user'
|
||||
? 'rgba(139,92,246,0.25)'
|
||||
: 'rgba(168,85,247,0.08)',
|
||||
borderRadius: msg.role === 'user' ? '8px 8px 2px 8px' : '8px 8px 8px 2px',
|
||||
padding: '6px 8px',
|
||||
fontSize: 10,
|
||||
color: '#e2e8f0',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start', padding: '6px 8px',
|
||||
background: 'rgba(168,85,247,0.08)', borderRadius: 8,
|
||||
fontSize: 10, color: '#a78bfa',
|
||||
}}>
|
||||
분석 중...
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 4, padding: '6px 8px',
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
background: 'rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }}
|
||||
placeholder="해양 상황 질문..."
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
flex: 1, background: 'rgba(139,92,246,0.06)',
|
||||
border: '1px solid rgba(139,92,246,0.2)',
|
||||
borderRadius: 4, padding: '5px 8px',
|
||||
fontSize: 10, color: '#e2e8f0', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isLoading || !input.trim()}
|
||||
style={{
|
||||
background: isLoading || !input.trim() ? '#334155' : '#7c3aed',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 10px', fontSize: 10, fontWeight: 700,
|
||||
color: '#fff', cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -109,6 +109,7 @@ interface Props {
|
||||
ships: Ship[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
onClose: () => void;
|
||||
onReport?: () => void;
|
||||
}
|
||||
|
||||
const PIPE_STEPS = [
|
||||
@ -123,7 +124,7 @@ const PIPE_STEPS = [
|
||||
|
||||
const ALERT_ORDER: Record<string, number> = { CRITICAL: 0, WATCH: 1, MONITOR: 2, NORMAL: 3 };
|
||||
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
export function FieldAnalysisModal({ ships, vesselAnalysis, onClose, onReport }: Props) {
|
||||
const emptyMap = useMemo(() => new Map<string, VesselAnalysisDto>(), []);
|
||||
const analysisMap = vesselAnalysis?.analysisMap ?? emptyMap;
|
||||
const [activeFilter, setActiveFilter] = useState('ALL');
|
||||
@ -348,6 +349,19 @@ export function FieldAnalysisModal({ ships, vesselAnalysis, onClose }: Props) {
|
||||
LIVE
|
||||
</span>
|
||||
<span style={{ color: C.ink2, fontSize: 10 }}>{new Date().toLocaleTimeString('ko-KR')}</span>
|
||||
{onReport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReport}
|
||||
style={{
|
||||
background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)',
|
||||
color: '#60a5fa', padding: '4px 14px', cursor: 'pointer',
|
||||
fontSize: 11, borderRadius: 2, fontFamily: 'inherit', fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
📋 보고서
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
@ -492,7 +492,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
const panelStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 10,
|
||||
right: 10,
|
||||
zIndex: 10,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
|
||||
@ -60,6 +60,9 @@ interface Props {
|
||||
dokdoWatchSuspects: Set<string>;
|
||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
|
||||
onExternalFlyToDone?: () => void;
|
||||
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
|
||||
}
|
||||
|
||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||
@ -101,6 +104,93 @@ const MAP_STYLE = {
|
||||
],
|
||||
};
|
||||
|
||||
// ═══ Sea routing — avoid Korean peninsula land mass ═══
|
||||
// Coastal waypoints around Korea (clockwise from NW)
|
||||
const SEA_WAYPOINTS: [number, number][] = [
|
||||
[124.5, 37.8], // 서해 북부 (백령도 서)
|
||||
[124.0, 36.5], // 서해 중부
|
||||
[124.5, 35.5], // 서해 남부
|
||||
[125.0, 34.5], // 서남해 (신안)
|
||||
[126.0, 33.5], // 남해 서단 (제주 서)
|
||||
[126.5, 33.2], // 제주 남서
|
||||
[127.5, 33.0], // 제주 남
|
||||
[128.5, 33.5], // 제주 동
|
||||
[129.0, 34.5], // 남해 동단 (거제)
|
||||
[129.5, 35.2], // 부산 남
|
||||
[129.8, 36.0], // 동해 남부 (울산)
|
||||
[130.0, 37.0], // 동해 중부
|
||||
[129.5, 37.8], // 동해 북부 (강릉)
|
||||
[129.0, 38.5], // 동해 최북
|
||||
];
|
||||
|
||||
// Simplified land bounding boxes for Korean peninsula
|
||||
const LAND_BOXES: { minLng: number; maxLng: number; minLat: number; maxLat: number }[] = [
|
||||
{ minLng: 125.5, maxLng: 129.5, minLat: 34.3, maxLat: 38.6 }, // 한반도 본토
|
||||
{ minLng: 126.1, maxLng: 126.9, minLat: 33.2, maxLat: 33.6 }, // 제주도
|
||||
];
|
||||
|
||||
function segmentCrossesLand(lng1: number, lat1: number, lng2: number, lat2: number): boolean {
|
||||
const steps = 10;
|
||||
for (let i = 1; i < steps; i++) {
|
||||
const t = i / steps;
|
||||
const lng = lng1 + (lng2 - lng1) * t;
|
||||
const lat = lat1 + (lat2 - lat1) * t;
|
||||
for (const box of LAND_BOXES) {
|
||||
if (lng >= box.minLng && lng <= box.maxLng && lat >= box.minLat && lat <= box.maxLat) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function nearestWaypoint(lng: number, lat: number): number {
|
||||
let bestIdx = 0, bestDist = Infinity;
|
||||
for (let i = 0; i < SEA_WAYPOINTS.length; i++) {
|
||||
const d = (SEA_WAYPOINTS[i][0] - lng) ** 2 + (SEA_WAYPOINTS[i][1] - lat) ** 2;
|
||||
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
}
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
function buildSeaRoute(from: { lat: number; lng: number }, to: { lat: number; lng: number }): [number, number][] {
|
||||
// Direct line doesn't cross land → straight route
|
||||
if (!segmentCrossesLand(from.lng, from.lat, to.lng, to.lat)) {
|
||||
return [[from.lng, from.lat], [to.lng, to.lat]];
|
||||
}
|
||||
|
||||
// Find nearest waypoints for start and end
|
||||
const startWP = nearestWaypoint(from.lng, from.lat);
|
||||
const endWP = nearestWaypoint(to.lng, to.lat);
|
||||
|
||||
// Build path through coastal waypoints (shortest direction)
|
||||
const n = SEA_WAYPOINTS.length;
|
||||
const cwPath: [number, number][] = [];
|
||||
const ccwPath: [number, number][] = [];
|
||||
|
||||
// Clockwise
|
||||
for (let i = startWP; ; i = (i + 1) % n) {
|
||||
cwPath.push(SEA_WAYPOINTS[i]);
|
||||
if (i === endWP) break;
|
||||
if (cwPath.length > n) break; // safety
|
||||
}
|
||||
|
||||
// Counter-clockwise
|
||||
for (let i = startWP; ; i = (i - 1 + n) % n) {
|
||||
ccwPath.push(SEA_WAYPOINTS[i]);
|
||||
if (i === endWP) break;
|
||||
if (ccwPath.length > n) break;
|
||||
}
|
||||
|
||||
const waypoints = cwPath.length <= ccwPath.length ? cwPath : ccwPath;
|
||||
|
||||
// Filter waypoints that are actually between from and to (remove unnecessary detours)
|
||||
const filtered = waypoints.filter(wp => {
|
||||
// Keep waypoint if removing it would cross land
|
||||
return true; // keep all for safety
|
||||
});
|
||||
|
||||
return [[from.lng, from.lat], ...filtered, [to.lng, to.lat]];
|
||||
}
|
||||
|
||||
// Korea-centered view
|
||||
const KOREA_MAP_CENTER = { longitude: 127.5, latitude: 36 };
|
||||
const KOREA_MAP_ZOOM = 6;
|
||||
@ -132,7 +222,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
||||
ferryWatch: 'filters.ferryWatchMonitor',
|
||||
};
|
||||
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis }: Props) {
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
@ -156,6 +246,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}
|
||||
}, [flyToTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalFlyTo && mapRef.current) {
|
||||
mapRef.current.flyTo({ center: [externalFlyTo.lng, externalFlyTo.lat], zoom: externalFlyTo.zoom, duration: 1500 });
|
||||
onExternalFlyToDone?.();
|
||||
}
|
||||
}, [externalFlyTo, onExternalFlyToDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAnalysisMmsi) setTrackCoords(null);
|
||||
}, [selectedAnalysisMmsi]);
|
||||
@ -933,6 +1030,54 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
onExpandedChange={setAnalysisPanelOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 작전가이드 임검침로 점선 — 해상 루트 (육지 우회) */}
|
||||
{opsRoute && (() => {
|
||||
const riskColor = opsRoute.riskLevel === 'CRITICAL' ? '#ef4444' : opsRoute.riskLevel === 'HIGH' ? '#f59e0b' : '#3b82f6';
|
||||
const coords = buildSeaRoute(opsRoute.from, opsRoute.to);
|
||||
const routeGeoJson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature', properties: {},
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
}],
|
||||
};
|
||||
const midIdx = Math.floor(coords.length / 2);
|
||||
const midLng = coords[midIdx][0];
|
||||
const midLat = coords[midIdx][1];
|
||||
return (
|
||||
<>
|
||||
<Source id="ops-route-line" type="geojson" data={routeGeoJson}>
|
||||
<Layer id="ops-route-dash" type="line" paint={{
|
||||
'line-color': riskColor,
|
||||
'line-width': 2.5,
|
||||
'line-dasharray': [4, 4],
|
||||
'line-opacity': 0.8,
|
||||
}} />
|
||||
</Source>
|
||||
<Marker longitude={opsRoute.from.lng} latitude={opsRoute.from.lat} anchor="center">
|
||||
<div style={{ fontSize: 18, filter: 'drop-shadow(0 0 4px rgba(0,0,0,0.8))' }}>⚓</div>
|
||||
</Marker>
|
||||
<Marker longitude={opsRoute.to.lng} latitude={opsRoute.to.lat} anchor="center">
|
||||
<div style={{
|
||||
width: 12, height: 12, borderRadius: '50%',
|
||||
background: riskColor, border: '2px solid #fff',
|
||||
boxShadow: `0 0 8px ${riskColor}`,
|
||||
}} />
|
||||
</Marker>
|
||||
<Marker longitude={midLng} latitude={midLat} anchor="bottom">
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.8)', padding: '2px 6px', borderRadius: 3,
|
||||
border: `1px solid ${riskColor}`, fontSize: 9, color: '#fff', fontWeight: 700,
|
||||
whiteSpace: 'nowrap', textAlign: 'center',
|
||||
}}>
|
||||
{opsRoute.distanceNM.toFixed(1)} NM
|
||||
<div style={{ fontSize: 7, color: riskColor }}>{opsRoute.from.name} → {opsRoute.to.name}</div>
|
||||
</div>
|
||||
</Marker>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
410
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
410
frontend/src/components/korea/OpsGuideModal.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { COAST_GUARD_FACILITIES, CG_TYPE_LABEL } from '../../services/coastGuard';
|
||||
import type { CoastGuardFacility } from '../../services/coastGuard';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { analyzeFishing, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
|
||||
export interface OpsRoute {
|
||||
from: { lat: number; lng: number; name: string };
|
||||
to: { lat: number; lng: number; name: string; mmsi: string };
|
||||
distanceNM: number;
|
||||
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
onClose: () => void;
|
||||
onFlyTo?: (lat: number, lng: number, zoom: number) => void;
|
||||
onRouteSelect?: (route: OpsRoute | null) => void;
|
||||
}
|
||||
|
||||
interface SuspectVessel {
|
||||
ship: Ship;
|
||||
distance: number;
|
||||
reasons: string[];
|
||||
riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM';
|
||||
estimatedType: 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
function haversineNM(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 3440.065;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
const RISK_COLOR = { CRITICAL: '#ef4444', HIGH: '#f59e0b', MEDIUM: '#3b82f6' };
|
||||
const RISK_ICON = { CRITICAL: '🔴', HIGH: '🟡', MEDIUM: '🔵' };
|
||||
type Tab = 'detect' | 'procedure' | 'alert';
|
||||
|
||||
// ── 중국어 경고문 ──
|
||||
const CN_WARNINGS: Record<string, { zh: string; ko: string; usage: string }[]> = {
|
||||
PT: [
|
||||
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF Ch.16 + 확성기' },
|
||||
{ zh: '请出示捕捞许可证', ko: '어업허가증을 제시하시오', usage: '승선 검사 시' },
|
||||
{ zh: '请出示作业日志', ko: '조업일지를 제시하시오', usage: '어획량 확인' },
|
||||
{ zh: '你的网目不符合规定', ko: '망목이 규정에 미달합니다', usage: '어구 검사 (54mm 미만)' },
|
||||
],
|
||||
GN: [
|
||||
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀 대응' },
|
||||
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||
{ zh: '你在非许可区域作业', ko: '비허가 구역에서 조업 중입니다', usage: '수역 이탈 시' },
|
||||
{ zh: '请立即收回渔网', ko: '어망을 즉시 회수하시오', usage: '불법 자망 발견' },
|
||||
],
|
||||
PS: [
|
||||
{ zh: '所有船只立即停止作业', ko: '모든 선박 즉시 조업 중단', usage: '선단 제압 시' },
|
||||
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||
{ zh: '关闭集鱼灯', ko: '집어등을 끄시오', usage: '조명선 대응' },
|
||||
{ zh: '不要试图逃跑', ko: '도주를 시도하지 마시오', usage: '도주 시' },
|
||||
],
|
||||
FC: [
|
||||
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: 'VHF + 확성기' },
|
||||
{ zh: '请出示货物清单', ko: '화물 목록을 제시하시오', usage: '환적 검사' },
|
||||
{ zh: '禁止转运渔获物', ko: '어획물 환적을 금지합니다', usage: '환적 현장' },
|
||||
],
|
||||
GEAR: [
|
||||
{ zh: '这些渔具属于非法设置', ko: '이 어구는 불법 설치되었습니다', usage: '어구 수거 시' },
|
||||
],
|
||||
UNKNOWN: [
|
||||
{ zh: '请立即停船接受检查', ko: '즉시 정선하여 검사를 받으시오', usage: '기본 경고' },
|
||||
{ zh: '请打开AIS', ko: 'AIS를 켜시오', usage: '다크베셀' },
|
||||
],
|
||||
};
|
||||
|
||||
function estimateVesselType(ship: Ship): 'PT' | 'GN' | 'PS' | 'FC' | 'GEAR' | 'UNKNOWN' {
|
||||
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const isGear = /[_]\d+[_]|%$/.test(ship.name);
|
||||
if (isGear) return 'GEAR';
|
||||
if (cat === 'cargo' || (cat === 'unspecified' && (ship.length || 0) > 50)) return 'FC';
|
||||
if (cat !== 'fishing' && ship.category !== 'fishing' && ship.typecode !== '30') return 'UNKNOWN';
|
||||
const spd = ship.speed || 0;
|
||||
if (spd >= 7) return 'PS';
|
||||
if (spd < 1.5) return 'GN';
|
||||
return 'PT';
|
||||
}
|
||||
|
||||
export function OpsGuideModal({ ships, onClose, onFlyTo, onRouteSelect }: Props) {
|
||||
const [selectedKCG, setSelectedKCG] = useState<CoastGuardFacility | null>(null);
|
||||
const [searchRadius, setSearchRadius] = useState(30);
|
||||
const [pos, setPos] = useState({ x: 60, y: 60 });
|
||||
const [tab, setTab] = useState<Tab>('detect');
|
||||
const [selectedSuspect, setSelectedSuspect] = useState<SuspectVessel | null>(null);
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||||
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
|
||||
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
setPos({ x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: dragRef.current.origY + (ev.clientY - dragRef.current.startY) });
|
||||
};
|
||||
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [pos]);
|
||||
|
||||
const kcgBases = useMemo(() =>
|
||||
COAST_GUARD_FACILITIES.filter(f => ['hq', 'regional', 'station', 'navy'].includes(f.type)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[]);
|
||||
|
||||
const suspects = useMemo<SuspectVessel[]>(() => {
|
||||
if (!selectedKCG) return [];
|
||||
const results: SuspectVessel[] = [];
|
||||
for (const ship of ships) {
|
||||
if (ship.flag !== 'CN') continue;
|
||||
const dist = haversineNM(selectedKCG.lat, selectedKCG.lng, ship.lat, ship.lng);
|
||||
if (dist > searchRadius) continue;
|
||||
const cat = getMarineTrafficCategory(ship.typecode, ship.category);
|
||||
const isFishing = cat === 'fishing' || ship.category === 'fishing' || ship.typecode === '30';
|
||||
const isGear = /[_]\d+[_]|%$/.test(ship.name);
|
||||
const zone = classifyFishingZone(ship.lat, ship.lng);
|
||||
const reasons: string[] = [];
|
||||
let riskLevel: 'CRITICAL' | 'HIGH' | 'MEDIUM' = 'MEDIUM';
|
||||
if (isFishing && zone.zone === 'OUTSIDE') { reasons.push('비허가 수역 진입'); riskLevel = 'CRITICAL'; }
|
||||
if (isFishing && zone.zone === 'ZONE_I' && ship.speed >= 2 && ship.speed <= 5) { reasons.push('수역I 저인망 의심'); riskLevel = 'HIGH'; }
|
||||
if (isFishing && ship.speed === 0 && (!ship.heading || ship.heading === 0)) { reasons.push('다크베셀 의심'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
|
||||
if (isFishing && ship.speed >= 2 && ship.speed <= 6) reasons.push(`조업 추정 (${ship.speed.toFixed(1)}kn)`);
|
||||
if (isGear) { reasons.push('어구/어망 AIS'); if (riskLevel === 'MEDIUM') riskLevel = 'HIGH'; }
|
||||
if (!isFishing && (cat === 'cargo' || cat === 'unspecified') && ship.speed < 3) reasons.push('운반선/환적 의심');
|
||||
if (reasons.length > 0) results.push({ ship, distance: dist, reasons, riskLevel, estimatedType: estimateVesselType(ship) });
|
||||
}
|
||||
return results.sort((a, b) => ({ CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[a.riskLevel] - { CRITICAL: 0, HIGH: 1, MEDIUM: 2 }[b.riskLevel]) || a.distance - b.distance);
|
||||
}, [selectedKCG, ships, searchRadius]);
|
||||
|
||||
const criticalCount = suspects.filter(s => s.riskLevel === 'CRITICAL').length;
|
||||
const highCount = suspects.filter(s => s.riskLevel === 'HIGH').length;
|
||||
|
||||
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
|
||||
|
||||
const copyToClipboard = (text: string, idx: number) => {
|
||||
navigator.clipboard.writeText(text).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1500); });
|
||||
};
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const speakChinese = useCallback((text: string, idx: number) => {
|
||||
// Stop previous
|
||||
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
|
||||
setSpeakingIdx(idx);
|
||||
const encoded = encodeURIComponent(text);
|
||||
const url = `/api/gtts?ie=UTF-8&client=tw-ob&tl=zh-CN&q=${encoded}`;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => setSpeakingIdx(null);
|
||||
audio.onerror = () => setSpeakingIdx(null);
|
||||
audio.play().catch(() => setSpeakingIdx(null));
|
||||
}, []);
|
||||
|
||||
const handleSuspectClick = (s: SuspectVessel) => {
|
||||
setSelectedSuspect(s);
|
||||
setTab('procedure');
|
||||
onFlyTo?.(s.ship.lat, s.ship.lng, 10);
|
||||
if (selectedKCG) {
|
||||
onRouteSelect?.({ from: { lat: selectedKCG.lat, lng: selectedKCG.lng, name: selectedKCG.name }, to: { lat: s.ship.lat, lng: s.ship.lng, name: s.ship.name || s.ship.mmsi, mmsi: s.ship.mmsi }, distanceNM: s.distance, riskLevel: s.riskLevel });
|
||||
}
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = { PT: '저인망(PT)', GN: '유자망(GN)', PS: '위망(PS)', FC: '운반선(FC)', GEAR: '어구/어망', UNKNOWN: '미분류' };
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', left: pos.x, top: pos.y, zIndex: 9999, width: 760, maxHeight: '85vh', overflow: 'hidden', background: '#0a0f1a', borderRadius: 8, border: '1px solid #1e293b', display: 'flex', flexDirection: 'column', boxShadow: '0 8px 32px rgba(0,0,0,0.6)', resize: 'both' }}>
|
||||
{/* Header */}
|
||||
<div onMouseDown={onDragStart} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderBottom: '1px solid #1e293b', background: 'rgba(30,58,95,0.5)', cursor: 'grab', userSelect: 'none', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 10, color: '#475569', letterSpacing: 2 }}>⠿</span>
|
||||
<span style={{ fontSize: 14 }}>⚓</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>경비함정 작전 가이드</span>
|
||||
<button onClick={onClose} style={{ marginLeft: 'auto', background: 'rgba(255,82,82,0.1)', border: '1px solid rgba(255,82,82,0.4)', color: '#ef4444', padding: '4px 12px', cursor: 'pointer', fontSize: 10, borderRadius: 2 }}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 2, padding: '4px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0 }}>
|
||||
{([['detect', '🔍 실시간 탐지'], ['procedure', '📋 대응 절차'], ['alert', '🚨 조치 기준']] as [Tab, string][]).map(([k, l]) => (
|
||||
<button key={k} onClick={() => setTab(k)} style={{
|
||||
padding: '3px 10px', fontSize: 10, fontWeight: 700, borderRadius: 3, cursor: 'pointer',
|
||||
background: tab === k ? 'rgba(59,130,246,0.2)' : 'transparent',
|
||||
border: tab === k ? '1px solid rgba(59,130,246,0.4)' : '1px solid transparent',
|
||||
color: tab === k ? '#60a5fa' : '#64748b',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls (detect tab) */}
|
||||
{tab === 'detect' && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '6px 16px', borderBottom: '1px solid #1e293b', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select value={selectedKCG?.id ?? ''} onChange={e => { const f = kcgBases.find(b => b.id === Number(e.target.value)); setSelectedKCG(f || null); }} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0', minWidth: 160 }}>
|
||||
<option value="">출동 기지 선택</option>
|
||||
{kcgBases.map(b => <option key={b.id} value={b.id}>[{CG_TYPE_LABEL[b.type]}] {b.name}</option>)}
|
||||
</select>
|
||||
<select value={searchRadius} onChange={e => setSearchRadius(Number(e.target.value))} style={{ background: '#1e293b', border: '1px solid #334155', borderRadius: 4, padding: '3px 8px', fontSize: 10, color: '#e2e8f0' }}>
|
||||
{[10, 20, 30, 50, 100].map(n => <option key={n} value={n}>{n} NM</option>)}
|
||||
</select>
|
||||
{selectedKCG && <div style={{ marginLeft: 'auto', display: 'flex', gap: 6, fontSize: 9 }}>
|
||||
<span style={{ color: '#ef4444', fontWeight: 700 }}>🔴 {criticalCount}</span>
|
||||
<span style={{ color: '#f59e0b', fontWeight: 700 }}>🟡 {highCount}</span>
|
||||
<span style={{ color: '#3b82f6', fontWeight: 700 }}>🔵 {suspects.length}</span>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '8px 16px', minHeight: 200 }}>
|
||||
|
||||
{/* ── TAB: 실시간 탐지 ── */}
|
||||
{tab === 'detect' && (<>
|
||||
{!selectedKCG ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>⚓ 출동 기지를 선택하면 주변 불법어선·어구를 자동 탐지합니다</div>
|
||||
) : suspects.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '30px 0', color: '#22c55e', fontSize: 11 }}>✅ {selectedKCG.name} 반경 {searchRadius}NM 내 의심 선박 없음</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{suspects.map((s, i) => (
|
||||
<div key={s.ship.mmsi} onClick={() => handleSuspectClick(s)} style={{
|
||||
background: '#111827', borderRadius: 4, padding: '6px 10px', borderLeft: `3px solid ${RISK_COLOR[s.riskLevel]}`, cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 10 }}>
|
||||
<span style={{ color: '#475569', minWidth: 18 }}>#{i + 1}</span>
|
||||
<span>{RISK_ICON[s.riskLevel]}</span>
|
||||
<span style={{ fontSize: 8, fontWeight: 700, padding: '0 4px', borderRadius: 2, background: RISK_COLOR[s.riskLevel] + '20', color: RISK_COLOR[s.riskLevel] }}>{s.riskLevel}</span>
|
||||
<span style={{ fontWeight: 700, color: '#e2e8f0' }}>{s.ship.name || s.ship.mmsi}</span>
|
||||
<span style={{ fontSize: 8, color: '#64748b' }}>[{TYPE_LABEL[s.estimatedType]}]</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#60a5fa', fontWeight: 700 }}>{s.distance.toFixed(1)} NM</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 3, marginTop: 2, flexWrap: 'wrap' }}>
|
||||
{s.reasons.map((r, j) => <span key={j} style={{ fontSize: 7, padding: '0 4px', borderRadius: 2, background: 'rgba(255,255,255,0.05)', color: '#94a3b8' }}>{r}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{/* ── TAB: 대응 절차 ── */}
|
||||
{tab === 'procedure' && (<>
|
||||
{selectedSuspect ? (
|
||||
<div style={{ fontSize: 10, color: '#e2e8f0', lineHeight: 1.7 }}>
|
||||
{/* 선박 정보 */}
|
||||
<div style={{ background: '#111827', borderRadius: 6, padding: '8px 12px', border: `1px solid ${RISK_COLOR[selectedSuspect.riskLevel]}30`, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{RISK_ICON[selectedSuspect.riskLevel]}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 12 }}>{selectedSuspect.ship.name || selectedSuspect.ship.mmsi}</span>
|
||||
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 3, background: RISK_COLOR[selectedSuspect.riskLevel] + '20', color: RISK_COLOR[selectedSuspect.riskLevel], fontWeight: 700 }}>{selectedSuspect.riskLevel}</span>
|
||||
<span style={{ fontSize: 9, color: '#64748b' }}>추정: {TYPE_LABEL[selectedSuspect.estimatedType]}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginTop: 2 }}>MMSI: {selectedSuspect.ship.mmsi} | SOG: {selectedSuspect.ship.speed?.toFixed(1)}kn | {selectedSuspect.distance.toFixed(1)} NM</div>
|
||||
</div>
|
||||
|
||||
{/* 업종별 대응 절차 */}
|
||||
<ProcedureSteps type={selectedSuspect.estimatedType} />
|
||||
|
||||
{/* 중국어 경고문 */}
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid #1e293b', paddingTop: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#f59e0b', marginBottom: 6 }}>📢 중국어 경고문 (클릭: 복사 | 🔊: 음성)</div>
|
||||
{(CN_WARNINGS[selectedSuspect.estimatedType] || CN_WARNINGS.UNKNOWN).map((w, i) => (
|
||||
<div key={i} style={{
|
||||
background: copiedIdx === i ? 'rgba(34,197,94,0.15)' : speakingIdx === i ? 'rgba(251,191,36,0.1)' : '#111827',
|
||||
border: copiedIdx === i ? '1px solid rgba(34,197,94,0.4)' : speakingIdx === i ? '1px solid rgba(251,191,36,0.4)' : '1px solid #1e293b',
|
||||
borderRadius: 4, padding: '6px 10px', marginBottom: 4, transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
}}>
|
||||
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => copyToClipboard(w.zh, i)}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#fbbf24' }}>{w.zh}</div>
|
||||
<div style={{ fontSize: 9, color: '#94a3b8' }}>{w.ko}</div>
|
||||
<div style={{ fontSize: 8, color: '#475569' }}>
|
||||
사용: {w.usage}
|
||||
{copiedIdx === i && <span style={{ color: '#22c55e', fontWeight: 700, marginLeft: 4 }}>✓ 복사됨</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); speakChinese(w.zh, i); }}
|
||||
style={{
|
||||
background: speakingIdx === i ? 'rgba(251,191,36,0.3)' : 'rgba(251,191,36,0.1)',
|
||||
border: '1px solid rgba(251,191,36,0.3)',
|
||||
borderRadius: 4, padding: '4px 8px', cursor: 'pointer',
|
||||
fontSize: 14, lineHeight: 1, flexShrink: 0,
|
||||
animation: speakingIdx === i ? 'pulse 1s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
title="중국어 음성 재생"
|
||||
>
|
||||
{speakingIdx === i ? '🔊' : '🔈'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '30px 0', color: '#64748b', fontSize: 11 }}>
|
||||
실시간 탐지 탭에서 의심 선박을 클릭하면<br/>해당 업종별 대응 절차가 자동 표시됩니다
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
{/* ── TAB: 조치 기준 ── */}
|
||||
{tab === 'alert' && (<AlertTable />)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '4px 16px', borderTop: '1px solid #1e293b', fontSize: 8, color: '#475569', flexShrink: 0 }}>
|
||||
GC-KCG-2026-001 기반 | 허가현황 906척 | 수역: Point-in-Polygon | 중국어 경고문 클릭 시 클립보드 복사
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 업종별 대응 절차 컴포넌트 ──
|
||||
const step: React.CSSProperties = { background: '#1e293b', borderRadius: 4, padding: '6px 10px', margin: '4px 0' };
|
||||
const stepN: React.CSSProperties = { display: 'inline-block', background: '#3b82f6', color: '#fff', borderRadius: 3, padding: '0 5px', fontSize: 8, fontWeight: 700, marginRight: 4 };
|
||||
const warn: React.CSSProperties = { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 4, padding: '4px 8px', margin: '4px 0', fontSize: 9, color: '#fca5a5' };
|
||||
|
||||
function ProcedureSteps({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'PT': return (<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🔴 2척식 저인망 (PT) 대응 절차</div>
|
||||
<div style={warn}>⚠ 선미(船尾) 방향 접근 절대 금지 — 예인삭 스크루 감김 위험</div>
|
||||
<div style={step}><span style={stepN}>1</span><b>탐지/식별</b> — AIS MMSI → 허가DB 대조. 본선·부속선 쌍 확인, 이격거리 측정</div>
|
||||
<div style={step}><span style={stepN}>2</span><b>접근/경고</b> — 선수 45° 측면 접근. VHF Ch.16 경고 3회. 중국어 방송 병행</div>
|
||||
<div style={step}><span style={stepN}>3</span><b>승선 검사</b> — ①허가증(C21-xxxxx) ②조업일지(할당량 100톤/척) ③망목 실측(54mm)</div>
|
||||
<div style={step}><span style={stepN}>4</span><b>위반 판정</b> — 휴어기(4/16~10/15)→나포 | 할당초과→압수 | 부속선 분리→양선 나포</div>
|
||||
<div style={step}><span style={stepN}>5</span><b>나포/방면</b> — 위반: 목포·여수·제주·태안 입항. 경미: 경고 후 방면. 알람 기록 등록</div>
|
||||
</>);
|
||||
case 'GN': return (<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟡 유자망 (GN) 대응 — 다크베셀 주의</div>
|
||||
<div style={warn}>⚠ 부표 위치 먼저 확인 → 그물 범위 외곽으로 접근 (스크루 감김 방지)</div>
|
||||
<div style={step}><span style={stepN}>1</span><b>다크베셀 탐지</b> — 레이더 탐색 + SAR 요청. 부표 다수 발견 → 1NM 이내 집중 수색</div>
|
||||
<div style={step}><span style={stepN}>2</span><b>그물 확인 후 접근</b> — 부표 배치방향 → 자망 연장선 추정 → 수직 90° 외곽 접근</div>
|
||||
<div style={step}><span style={stepN}>3</span><b>AIS 재가동</b> — "请打开AIS" 경고. 재개 확인 후 MMSI 기록. 거부 시 강제 임검</div>
|
||||
<div style={step}><span style={stepN}>4</span><b>승선 검사</b> — ①허가증(C25-xxxxx) ②수역확인(I발견→위반) ③어획량(28톤/척) ④망목·규모</div>
|
||||
<div style={step}><span style={stepN}>5</span><b>어구 판정</b> — 허가외 자망→수거/절단. 망목미달→전량압수. GPS·사진 기록</div>
|
||||
</>);
|
||||
case 'PS': return (<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟣 위망 (PS) 선단 대응 — 선단 분산 주의</div>
|
||||
<div style={warn}>⚠ 단독 접근 금지 — 조명선 시야교란, 분산도주 전술 대비. 대형 함정 지원 후 동시 제압</div>
|
||||
<div style={step}><span style={stepN}>1</span><b>선단 확인/보고</b> — 원형궤적 + 고속→저속 패턴. 3척+ 클러스터. 즉시 상급 보고</div>
|
||||
<div style={step}><span style={stepN}>2</span><b>집어등 식별</b> — 야간 EO/육안. 조명선 MMSI 기록. 차단은 최후 단계</div>
|
||||
<div style={step}><span style={stepN}>3</span><b>선단 포위</b> — 모선·운반선·조명선 동시 포위. 서방(중국측) 탈주 차단 우선</div>
|
||||
<div style={step}><span style={stepN}>4</span><b>일제 임검</b> — 모선: C23-xxxxx, 1,500톤/척. 운반선/조명선: 0톤→적재 시 불법</div>
|
||||
<div style={step}><span style={stepN}>5</span><b>나포/증거</b> — 어획물·냉동설비 촬영. 宁波海裕 VHF 교신 확보. 목포·여수항 인계</div>
|
||||
</>);
|
||||
case 'FC': return (<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🟠 운반선 (FC) 환적 대응</div>
|
||||
<div style={step}><span style={stepN}>1</span><b>환적 알람</b> — FC+조업선 0.5NM + 양쪽 2kn + 30분 → HIGH. 좌표 즉시 이동</div>
|
||||
<div style={step}><span style={stepN}>2</span><b>증거 촬영</b> — 접현/고무보트 확인. 드론 항공촬영. MMSI·선명·접현 흔적 기록</div>
|
||||
<div style={step}><span style={stepN}>3</span><b>양선 임검</b> — 운반선: 화물·출발지·도착지. 조업선: 허가량 대비 실어획량</div>
|
||||
<div style={step}><span style={stepN}>4</span><b>증거/조치</b> — 사진·중량 확보. 필요시 전량 압수. 도주시 경고사격. 최근접 항구 입항</div>
|
||||
</>);
|
||||
case 'GEAR': return (<>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#60a5fa', marginBottom: 4 }}>🪤 불법 어구 수거 절차</div>
|
||||
<div style={warn}>⚠ 방치 자망 스크루 감김 주의 — 엔진 정지/저속 상태에서 수동 회수. 야간 수거 원칙적 연기</div>
|
||||
<div style={step}><span style={stepN}>1</span><b>발견/기록</b> — GPS(WGS84), 종류 추정, 사진, 소유자번호, 규모(길이·폭·그물코)</div>
|
||||
<div style={step}><span style={stepN}>2</span><b>중국어구 판단</b> — 중국어 부표, 광폭·장형 구조. 인근 중국어선 확인. 불가→항구 감식</div>
|
||||
<div style={step}><span style={stepN}>3</span><b>수거 실행</b> — RIB/크레인. 어획물→전량 압수. 절단 시 위치·잔존 기록</div>
|
||||
<div style={step}><span style={stepN}>4</span><b>수거 보고</b> — 감시 시스템 등록. 항구 감식·증거 보존. 반복 발견→집중 감시 지정</div>
|
||||
</>);
|
||||
default: return (<div style={{ color: '#64748b', fontSize: 10 }}>선박 유형을 식별할 수 없습니다. 기본 임검 절차를 적용하세요.</div>);
|
||||
}
|
||||
}
|
||||
|
||||
function AlertTable() {
|
||||
const rows = [
|
||||
{ type: '미등록 선박', criteria: 'MMSI 허가DB 미등록', action: '즉시 정선·나포', level: 'CRITICAL', note: '허가증 불소지 추가 확인' },
|
||||
{ type: '휴어기 조업', criteria: 'C21·C22: 4/16~10/15\nC25: 6/2~8/31', action: '즉시 나포', level: 'CRITICAL', note: '날짜 자동 판별' },
|
||||
{ type: '허가 수역 이탈', criteria: '비허가 수역 진입', action: '경고 후 나포', level: 'HIGH', note: 'PT: I·IV이탈 GN: I이탈' },
|
||||
{ type: 'PT 부속선 분리', criteria: '본선 이격 3NM+', action: '양선 동시 나포', level: 'HIGH→CRIT', note: '311쌍 실시간 모니터링' },
|
||||
{ type: '환적 현장 포착', criteria: 'FC+조업선 0.5NM+2kn+30분', action: '촬영 후 양선 나포', level: 'HIGH', note: '증거 촬영 최우선' },
|
||||
{ type: '불법 어구 발견', criteria: '표지 없음/미허가', action: '즉시 수거·기록', level: '자체판단', note: 'GPS 등록, 반복 요주의' },
|
||||
{ type: '할당량 초과', criteria: '80~100%+ 초과', action: '계량·초과 시 압수', level: 'CRITICAL', note: 'GN 28톤 현장 계량' },
|
||||
{ type: '다크베셀', criteria: 'AIS 공백 6시간+', action: '접근·임검', level: 'HIGH', note: 'SAR 교차 확인' },
|
||||
];
|
||||
const lc = (l: string) => l.includes('CRIT') ? '#ef4444' : l === 'HIGH' ? '#f59e0b' : '#64748b';
|
||||
return (
|
||||
<div style={{ fontSize: 10, color: '#e2e8f0' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#60a5fa', marginBottom: 6 }}>🚨 단속 상황별 조치 기준</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9 }}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={th}>위반 유형</th><th style={th}>판정 기준</th><th style={th}>즉시 조치</th><th style={th}>알람</th><th style={th}>비고</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}><td style={td}>{r.type}</td><td style={{ ...td, whiteSpace: 'pre-line' }}>{r.criteria}</td><td style={td}>{r.action}</td>
|
||||
<td style={{ ...td, color: lc(r.level), fontWeight: 700 }}>{r.level}</td><td style={{ ...td, color: '#64748b', fontSize: 8 }}>{r.note}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: 12, fontSize: 11, fontWeight: 700, color: '#60a5fa' }}>📅 감시 강화 시기</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 9, marginTop: 4 }}>
|
||||
<thead><tr style={{ background: '#1e293b' }}><th style={th}>시기</th><th style={th}>상황</th><th style={th}>대응</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={{ ...td, fontWeight: 700 }}>7~8월</td><td style={td}>PS 16척만 허가</td><td style={{ ...td, color: '#ef4444' }}>C21·C22·C25 전원 비허가</td></tr>
|
||||
<tr><td style={{ ...td, fontWeight: 700 }}>5월</td><td style={td}>GN만 허가</td><td style={td}>저인망(C21·C22) 즉시 위반</td></tr>
|
||||
<tr><td style={{ ...td, fontWeight: 700 }}>4월·10월</td><td style={td}>기간 경계</td><td style={td}>4/16, 10/16 집중 모니터링</td></tr>
|
||||
<tr><td style={{ ...td, fontWeight: 700 }}>1~3월</td><td style={td}>전 업종 가능</td><td style={td}>수역이탈·할당초과 중심</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 6px', textAlign: 'left', color: '#e2e8f0', fontSize: 8, fontWeight: 700 };
|
||||
const td: React.CSSProperties = { border: '1px solid #1e293b', padding: '2px 6px', fontSize: 9 };
|
||||
258
frontend/src/components/korea/ReportModal.tsx
Normal file
258
frontend/src/components/korea/ReportModal.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import type { Ship } from '../../types';
|
||||
import { getMarineTrafficCategory } from '../../utils/marineTraffic';
|
||||
import { aggregateFishingStats, GEAR_LABELS, classifyFishingZone } from '../../utils/fishingAnalysis';
|
||||
import type { FishingGearType } from '../../utils/fishingAnalysis';
|
||||
|
||||
interface Props {
|
||||
ships: Ship[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function now() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ReportModal({ ships, onClose }: Props) {
|
||||
const reportRef = useRef<HTMLDivElement>(null);
|
||||
const timestamp = useMemo(() => now(), []);
|
||||
|
||||
// Ship statistics
|
||||
const stats = useMemo(() => {
|
||||
const kr = ships.filter(s => s.flag === 'KR');
|
||||
const cn = ships.filter(s => s.flag === 'CN');
|
||||
const cnFishing = cn.filter(s => {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
return cat === 'fishing' || s.category === 'fishing' || s.typecode === '30';
|
||||
});
|
||||
|
||||
// CN fishing by speed
|
||||
const cnAnchored = cnFishing.filter(s => s.speed < 1);
|
||||
const cnLowSpeed = cnFishing.filter(s => s.speed >= 1 && s.speed < 3);
|
||||
const cnOperating = cnFishing.filter(s => s.speed >= 2 && s.speed <= 6);
|
||||
const cnSailing = cnFishing.filter(s => s.speed > 6);
|
||||
|
||||
// Gear analysis
|
||||
const fishingStats = aggregateFishingStats(cn);
|
||||
|
||||
// Zone analysis
|
||||
const zoneStats: Record<string, number> = { ZONE_I: 0, ZONE_II: 0, ZONE_III: 0, ZONE_IV: 0, OUTSIDE: 0 };
|
||||
cnFishing.forEach(s => {
|
||||
const z = classifyFishingZone(s.lat, s.lng);
|
||||
zoneStats[z.zone] = (zoneStats[z.zone] || 0) + 1;
|
||||
});
|
||||
|
||||
// Dark vessels (AIS gap)
|
||||
const darkSuspect = cnFishing.filter(s => s.speed === 0 && (!s.heading || s.heading === 0));
|
||||
|
||||
// Ship types
|
||||
const byType: Record<string, number> = {};
|
||||
ships.forEach(s => {
|
||||
const cat = getMarineTrafficCategory(s.typecode, s.category);
|
||||
byType[cat] = (byType[cat] || 0) + 1;
|
||||
});
|
||||
|
||||
// By nationality top 10
|
||||
const byFlag: Record<string, number> = {};
|
||||
ships.forEach(s => { byFlag[s.flag || 'unknown'] = (byFlag[s.flag || 'unknown'] || 0) + 1; });
|
||||
const topFlags = Object.entries(byFlag).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
|
||||
return { total: ships.length, kr, cn, cnFishing, cnAnchored, cnLowSpeed, cnOperating, cnSailing, fishingStats, zoneStats, darkSuspect, byType, topFlags };
|
||||
}, [ships]);
|
||||
|
||||
const handlePrint = () => {
|
||||
const content = reportRef.current;
|
||||
if (!content) return;
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return;
|
||||
win.document.write(`
|
||||
<html><head><title>중국어선 감시현황 보고서 - ${timestamp}</title>
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; padding: 40px; color: #1a1a1a; line-height: 1.8; font-size: 12px; }
|
||||
h1 { font-size: 20px; border-bottom: 2px solid #1e3a5f; padding-bottom: 8px; color: #1e3a5f; }
|
||||
h2 { font-size: 15px; color: #1e3a5f; margin-top: 24px; border-left: 4px solid #1e3a5f; padding-left: 8px; }
|
||||
h3 { font-size: 13px; color: #333; margin-top: 16px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 8px 0; font-size: 11px; }
|
||||
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
|
||||
th { background: #1e3a5f; color: #fff; font-weight: 700; }
|
||||
tr:nth-child(even) { background: #f5f7fa; }
|
||||
.badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; }
|
||||
.critical { background: #dc2626; color: #fff; }
|
||||
.high { background: #f59e0b; color: #000; }
|
||||
.medium { background: #3b82f6; color: #fff; }
|
||||
.footer { margin-top: 30px; font-size: 9px; color: #888; border-top: 1px solid #ddd; padding-top: 8px; }
|
||||
@media print { body { padding: 20px; } }
|
||||
</style></head><body>${content.innerHTML}</body></html>
|
||||
`);
|
||||
win.document.close();
|
||||
win.print();
|
||||
};
|
||||
|
||||
const gearEntries = Object.entries(stats.fishingStats.byGear) as [FishingGearType, number][];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}} onClick={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: '90vw', maxWidth: 900, maxHeight: '90vh', overflow: 'auto',
|
||||
background: '#0f172a', borderRadius: 8, border: '1px solid rgba(99,179,237,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(30,58,95,0.5)',
|
||||
}}>
|
||||
<span style={{ fontSize: 14 }}>📋</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0' }}>중국어선 감시현황 분석 보고서</span>
|
||||
<span style={{ fontSize: 9, color: '#64748b' }}>{timestamp} 기준</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||
<button onClick={handlePrint} style={{
|
||||
background: '#3b82f6', border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#fff', cursor: 'pointer',
|
||||
}}>🖨 인쇄 / PDF</button>
|
||||
<button onClick={onClose} style={{
|
||||
background: '#334155', border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 10, fontWeight: 700, color: '#94a3b8', cursor: 'pointer',
|
||||
}}>✕ 닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Content */}
|
||||
<div ref={reportRef} style={{ padding: '16px 24px', color: '#cbd5e1', fontSize: 11, lineHeight: 1.7 }}>
|
||||
<h1 style={{ fontSize: 18, color: '#60a5fa', borderBottom: '2px solid #1e3a5f', paddingBottom: 6 }}>
|
||||
한중어업협정 기반 중국어선 감시 현황 분석 보고서
|
||||
</h1>
|
||||
<div style={{ fontSize: 9, color: '#64748b', marginBottom: 12 }}>
|
||||
문서번호: GC-KCG-RPT-AUTO | 생성일시: {timestamp} | 작성: KCG AI 자동분석 시스템 | 【대외비】
|
||||
</div>
|
||||
|
||||
{/* 1. 전체 현황 */}
|
||||
<h2 style={{ fontSize: 14, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 16 }}>1. 전체 해양 현황</h2>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 10 }}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>구분</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>전체 선박</td><td style={tdBold}>{stats.total.toLocaleString()}척</td><td style={tdStyle}>100%</td></tr>
|
||||
<tr><td style={tdStyle}>🇰🇷 한국 선박</td><td style={tdBold}>{stats.kr.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.kr.length, stats.total)}</td></tr>
|
||||
<tr><td style={tdStyle}>🇨🇳 중국 선박</td><td style={tdBold}>{stats.cn.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cn.length, stats.total)}</td></tr>
|
||||
<tr style={{ background: '#1e293b' }}><td style={tdStyle}>🇨🇳 중국어선</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnFishing.length.toLocaleString()}척</td><td style={tdStyle}>{pct(stats.cnFishing.length, stats.total)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 2. 중국어선 상세 */}
|
||||
<h2 style={h2Style}>2. 중국어선 활동 분석</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>활동 상태</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th><th style={thStyle}>판단 기준</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>⚓ 정박 (0~1kn)</td><td style={tdBold}>{stats.cnAnchored.length}</td><td style={tdStyle}>{pct(stats.cnAnchored.length, stats.cnFishing.length)}</td><td style={tdDim}>SOG {'<'} 1 knot</td></tr>
|
||||
<tr><td style={tdStyle}>🔵 저속 이동 (1~3kn)</td><td style={tdBold}>{stats.cnLowSpeed.length}</td><td style={tdStyle}>{pct(stats.cnLowSpeed.length, stats.cnFishing.length)}</td><td style={tdDim}>투·양망 또는 이동</td></tr>
|
||||
<tr style={{ background: 'rgba(245,158,11,0.1)' }}><td style={tdStyle}>🟡 조업 추정 (2~6kn)</td><td style={{ ...tdBold, color: '#f59e0b' }}>{stats.cnOperating.length}</td><td style={tdStyle}>{pct(stats.cnOperating.length, stats.cnFishing.length)}</td><td style={tdDim}>트롤/자망 조업 속도</td></tr>
|
||||
<tr><td style={tdStyle}>🟢 항해 중 (6+kn)</td><td style={tdBold}>{stats.cnSailing.length}</td><td style={tdStyle}>{pct(stats.cnSailing.length, stats.cnFishing.length)}</td><td style={tdDim}>이동/귀항</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 3. 어구별 분석 */}
|
||||
<h2 style={h2Style}>3. 어구/어망 유형별 분석</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>어구 유형</th><th style={thStyle}>추정 척수</th><th style={thStyle}>위험도</th><th style={thStyle}>탐지 신뢰도</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{gearEntries.map(([gear, count]) => {
|
||||
const meta = GEAR_LABELS[gear];
|
||||
return (
|
||||
<tr key={gear}>
|
||||
<td style={tdStyle}><span style={{ color: meta?.color || '#888' }}>{meta?.icon || '🎣'}</span> {meta?.label || gear}</td>
|
||||
<td style={tdBold}>{count}척</td>
|
||||
<td style={tdStyle}>{meta?.riskLevel === 'CRITICAL' ? '◉ CRITICAL' : meta?.riskLevel === 'HIGH' ? '⚠ HIGH' : '△ MED'}</td>
|
||||
<td style={tdStyle}>{meta?.confidence || '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 4. 수역별 분포 */}
|
||||
<h2 style={h2Style}>4. 특정어업수역별 분포</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>수역</th><th style={thStyle}>어선 수</th><th style={thStyle}>허가 업종 (3월)</th><th style={thStyle}>비고</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>수역 I (동해)</td><td style={tdBold}>{stats.zoneStats.ZONE_I}</td><td style={tdDim}>PS, FC만</td><td style={tdDim}>PT/OT/GN 발견 시 위반</td></tr>
|
||||
<tr><td style={tdStyle}>수역 II (남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_II}</td><td style={tdDim}>전 업종</td><td style={tdDim}>-</td></tr>
|
||||
<tr><td style={tdStyle}>수역 III (서남해)</td><td style={tdBold}>{stats.zoneStats.ZONE_III}</td><td style={tdDim}>전 업종</td><td style={tdDim}>이어도 해역</td></tr>
|
||||
<tr><td style={tdStyle}>수역 IV (서해)</td><td style={tdBold}>{stats.zoneStats.ZONE_IV}</td><td style={tdDim}>GN, PS, FC</td><td style={tdDim}>PT/OT 발견 시 위반</td></tr>
|
||||
<tr style={{ background: 'rgba(239,68,68,0.1)' }}><td style={tdStyle}>수역 외</td><td style={{ ...tdBold, color: '#ef4444' }}>{stats.zoneStats.OUTSIDE}</td><td style={tdDim}>-</td><td style={{ ...tdDim, color: '#ef4444' }}>비허가 구역</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 5. 위험 분석 */}
|
||||
<h2 style={h2Style}>5. 위험 평가</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>위험 유형</th><th style={thStyle}>현재 상태</th><th style={thStyle}>등급</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td style={tdStyle}>다크베셀 의심</td><td style={tdBold}>{stats.darkSuspect.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.darkSuspect.length > 10 ? '#dc2626' : '#f59e0b' }}>{stats.darkSuspect.length > 10 ? 'HIGH' : 'MEDIUM'}</span></td></tr>
|
||||
<tr><td style={tdStyle}>수역 외 어선</td><td style={tdBold}>{stats.zoneStats.OUTSIDE}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: stats.zoneStats.OUTSIDE > 0 ? '#dc2626' : '#22c55e' }}>{stats.zoneStats.OUTSIDE > 0 ? 'CRITICAL' : 'NORMAL'}</span></td></tr>
|
||||
<tr><td style={tdStyle}>조업 중 어선</td><td style={tdBold}>{stats.cnOperating.length}척</td><td style={tdStyle}><span style={{ ...badgeStyle, background: '#3b82f6' }}>MONITOR</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 6. 국적별 현황 */}
|
||||
<h2 style={h2Style}>6. 국적별 선박 현황 (TOP 10)</h2>
|
||||
<table style={tableStyle}>
|
||||
<thead><tr style={{ background: '#1e293b' }}>
|
||||
<th style={thStyle}>순위</th><th style={thStyle}>국적</th><th style={thStyle}>척수</th><th style={thStyle}>비율</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{stats.topFlags.map(([flag, count], i) => (
|
||||
<tr key={flag}><td style={tdStyle}>{i + 1}</td><td style={tdStyle}>{flag}</td><td style={tdBold}>{count.toLocaleString()}</td><td style={tdStyle}>{pct(count, stats.total)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 7. 건의사항 */}
|
||||
<h2 style={h2Style}>7. 건의사항</h2>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8', paddingLeft: 12 }}>
|
||||
<p>1. 현재 3월은 전 업종 조업 가능 기간으로, <strong style={{ color: '#f59e0b' }}>수역 이탈 및 본선-부속선 분리</strong> 중심 감시 권고</p>
|
||||
<p>2. 다크베셀 의심 {stats.darkSuspect.length}척에 대해 <strong style={{ color: '#ef4444' }}>SAR 위성 집중 탐색</strong> 요청</p>
|
||||
<p>3. 수역 외 어선 {stats.zoneStats.OUTSIDE}척에 대해 <strong style={{ color: '#ef4444' }}>즉시 현장 확인</strong> 필요</p>
|
||||
<p>4. 4/16 저인망 휴어기 진입 대비 <strong>감시 강화 계획 수립</strong> 권고</p>
|
||||
<p>5. 宁波海裕 위망 선단 16척 <strong>그룹 위치 상시 추적</strong> 유지</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ marginTop: 24, paddingTop: 8, borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: 8, color: '#475569' }}>
|
||||
본 보고서는 KCG 해양감시 시스템에서 자동 생성된 내부 참고자료입니다. | 생성: {timestamp} | 데이터: 실시간 AIS | 분석: AI 자동분석 엔진 | 【대외비】
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const h2Style: React.CSSProperties = { fontSize: 13, color: '#60a5fa', borderLeft: '3px solid #3b82f6', paddingLeft: 8, marginTop: 20 };
|
||||
const tableStyle: React.CSSProperties = { borderCollapse: 'collapse', width: '100%', fontSize: 10, marginTop: 6 };
|
||||
const thStyle: React.CSSProperties = { border: '1px solid #334155', padding: '4px 8px', textAlign: 'left', color: '#e2e8f0', fontSize: 9, fontWeight: 700 };
|
||||
const tdStyle: React.CSSProperties = { border: '1px solid #1e293b', padding: '3px 8px', fontSize: 10 };
|
||||
const tdBold: React.CSSProperties = { ...tdStyle, fontWeight: 700, color: '#e2e8f0' };
|
||||
const tdDim: React.CSSProperties = { ...tdStyle, color: '#64748b', fontSize: 9 };
|
||||
const badgeStyle: React.CSSProperties = { display: 'inline-block', padding: '1px 6px', borderRadius: 3, fontSize: 9, fontWeight: 700, color: '#fff' };
|
||||
|
||||
function pct(n: number, total: number): string {
|
||||
if (!total) return '-';
|
||||
return `${((n / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
@ -145,4 +145,57 @@ export const damagedShips: DamagedShip[] = [
|
||||
description: 'IRGC 게쉼섬 고속정 기지 공습. 고속정 20여 척 파괴/대파.',
|
||||
eventId: 'd12-us5',
|
||||
},
|
||||
|
||||
// ═══ S&P Global Marine Risk Note (2026-03-19) ═══
|
||||
// 이란의 상선 공격 30건 — 호르무즈 해협 중심
|
||||
// UAE 해역 48%, 오만 해역 28%, 기타 24%
|
||||
|
||||
// DAY 0 — 2026-03-01 (6척 동시 공격)
|
||||
{ id: 'spg-01', name: 'SKYLIGHT', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.12, lng: 56.28, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9396737, 마셜제도 국적 화학탱커. UAE 해역 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-02', name: 'STAR ELECTRA', flag: 'LR', type: 'Bulk Carrier', lat: 25.85, lng: 56.42, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9442536, 라이베리아 국적 벌크선. UAE 해역 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-03', name: 'HERCULES STAR', flag: 'GI', type: 'Products Tanker', lat: 26.35, lng: 56.15, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9916135, 지브롤터 국적 유조선. UAE 해역.', eventId: 'imp1' },
|
||||
{ id: 'spg-04', name: 'SEA LA DONNA', flag: 'LR', type: 'Chemical/Products Tanker', lat: 25.98, lng: 56.55, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9380532, 라이베리아 화학탱커.', eventId: 'imp1' },
|
||||
{ id: 'spg-05', name: 'OCEAN ELECTRA', flag: 'LR', type: 'Products Tanker', lat: 26.22, lng: 56.35, damagedAt: T0, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9402782, 라이베리아 유조선.', eventId: 'imp1' },
|
||||
{ id: 'spg-06', name: 'AYEH', flag: 'AE', type: 'Deck Cargo Ship', lat: 25.55, lng: 55.80, damagedAt: T0, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 1075181, UAE 국적 갑판화물선.', eventId: 'imp1' },
|
||||
|
||||
// DAY 1 — 2026-03-02 (3척)
|
||||
{ id: 'spg-07', name: 'STENA IMPERATIVE', flag: 'US', type: 'Chemical/Products Tanker', lat: 26.55, lng: 56.10, damagedAt: T0 + 1 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9666077, 미국 국적 화학탱커. 미국 선박 최초 피격.', eventId: 'imp1' },
|
||||
{ id: 'spg-08', name: 'MKD VYOM', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.70, lng: 56.90, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9284386, 마셜제도 원유탱커. 오만 해역.', eventId: 'imp1' },
|
||||
{ id: 'spg-09', name: 'ATHE NOVA', flag: 'HN', type: 'Asphalt/Bitumen Tanker', lat: 25.40, lng: 57.20, damagedAt: T0 + 1 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9188116, 온두라스 아스팔트탱커. 오만 해역.', eventId: 'imp1' },
|
||||
|
||||
// DAY 2 — 2026-03-03 (3척)
|
||||
{ id: 'spg-10', name: 'PELAGIA', flag: 'MT', type: 'Bulk Carrier', lat: 26.30, lng: 56.50, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9433626, 몰타 벌크선.', eventId: 'd3-sea1' },
|
||||
{ id: 'spg-11', name: 'GOLD OAK', flag: 'PA', type: 'Bulk Carrier', lat: 25.60, lng: 57.10, damagedAt: T0 + 2 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9806342, 파나마 벌크선. 오만 해역.', eventId: 'd3-sea1' },
|
||||
{ id: 'spg-12', name: 'LIBRA TRADER', flag: 'IN', type: 'Crude Oil Tanker', lat: 26.05, lng: 56.30, damagedAt: T0 + 2 * DAY, cause: 'IRGC 공격', damage: 'severe', description: 'IMO 9562673, 인도 원유탱커.', eventId: 'd3-sea1' },
|
||||
|
||||
// DAY 3 — 2026-03-04 (3척)
|
||||
{ id: 'spg-13', name: 'SAFEEN PRESTIGE', flag: 'MT', type: 'Container Ship', lat: 25.90, lng: 56.40, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9593517, 몰타 컨테이너선.', eventId: 'imp2' },
|
||||
{ id: 'spg-14', name: 'MSC GRACE', flag: 'LR', type: 'Container Ship', lat: 26.40, lng: 56.20, damagedAt: T0 + 3 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9987366, 라이베리아 MSC 컨테이너선.', eventId: 'imp2' },
|
||||
|
||||
// DAY 4 — 2026-03-05 (1척)
|
||||
{ id: 'spg-15', name: 'SONANGOL NAMIBE', flag: 'BS', type: 'Crude Oil Tanker', lat: 26.15, lng: 56.45, damagedAt: T0 + 4 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9325049, 바하마 원유탱커. UAE 해역.', eventId: 'imp2' },
|
||||
|
||||
// DAY 5 — 2026-03-06 (2척)
|
||||
{ id: 'spg-16', name: 'PRIMA', flag: 'MT', type: 'Tanker', lat: 25.80, lng: 56.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9427433, 몰타 탱커.', eventId: 'imp3' },
|
||||
{ id: 'spg-17', name: 'MUSSAFAH 2', flag: 'AE', type: 'Tug', lat: 25.50, lng: 55.60, damagedAt: T0 + 5 * DAY, cause: 'IRGC 공격', damage: 'minor', description: 'IMO 9522051, UAE 예인선.', eventId: 'imp3' },
|
||||
|
||||
// DAY 6 — 2026-03-07 (1척)
|
||||
{ id: 'spg-18', name: 'LOUIS P', flag: 'MH', type: 'Chemical/Products Tanker', lat: 26.60, lng: 56.05, damagedAt: T0 + 6 * DAY, cause: 'IRGC 드론', damage: 'moderate', description: 'IMO 9749336, 마셜제도 화학탱커.', eventId: 'imp3' },
|
||||
|
||||
// DAY 10 — 2026-03-11 (4척)
|
||||
{ id: 'spg-19', name: 'MAYUREE NAREE', flag: 'TH', type: 'Bulk Carrier', lat: 25.45, lng: 57.30, damagedAt: T0 + 10 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9323649, 태국 벌크선. 오만 해역.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-20', name: 'STAR GWYNETH', flag: 'MH', type: 'Bulk Carrier', lat: 26.20, lng: 56.55, damagedAt: T0 + 10 * DAY, cause: '기뢰 접촉', damage: 'moderate', description: 'IMO 9301031, 마셜제도 벌크선.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-21', name: 'ONE MAJESTY', flag: 'JP', type: 'Container Ship', lat: 25.75, lng: 56.80, damagedAt: T0 + 10 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9424912, 일본 ONE 컨테이너선. 오만 해역.', eventId: 'd12-sea1' },
|
||||
{ id: 'spg-22', name: 'EXPRESS ROME', flag: 'LR', type: 'Container Ship', lat: 26.00, lng: 56.35, damagedAt: T0 + 10 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9484936, 라이베리아 컨테이너선.', eventId: 'd12-sea1' },
|
||||
|
||||
// DAY 11 — 2026-03-12 (3척)
|
||||
{ id: 'spg-23', name: 'ZEFYROS', flag: 'MT', type: 'Chemical/Products Tanker', lat: 26.45, lng: 56.15, damagedAt: T0 + 11 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9515917, 몰타 화학탱커.', eventId: 'd12-sea3' },
|
||||
{ id: 'spg-24', name: 'SAFESEA VISHNU', flag: 'MH', type: 'Crude/Oil Products Tanker', lat: 25.65, lng: 57.00, damagedAt: T0 + 11 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9327009, 마셜제도 원유탱커. 오만 해역.', eventId: 'd12-sea3' },
|
||||
{ id: 'spg-25', name: 'SOURCE BLESSING', flag: 'LR', type: 'Container Ship', lat: 26.10, lng: 56.40, damagedAt: T0 + 11 * DAY, cause: 'IRGC 드론', damage: 'minor', description: 'IMO 9243198, 라이베리아 컨테이너선.', eventId: 'd12-sea3' },
|
||||
|
||||
// DAY 15 — 2026-03-16 (1척)
|
||||
{ id: 'spg-26', name: 'GAS AL AHMADIAH', flag: 'KW', type: 'LPG Tanker', lat: 29.20, lng: 48.80, damagedAt: T0 + 15 * DAY, cause: 'IRGC 대함미사일', damage: 'severe', description: 'IMO 9849629, 쿠웨이트 LPG탱커. 쿠웨이트 해역.', eventId: 'd12-sea6' },
|
||||
|
||||
// DAY 18 — 2026-03-19 (2척)
|
||||
{ id: 'spg-27', name: 'HALUL 69', flag: 'QA', type: 'Anchor Handling Tug Supply', lat: 25.95, lng: 51.55, damagedAt: T0 + 18 * DAY, cause: 'IRGC 공격', damage: 'moderate', description: 'IMO 9671577, 카타르 AHTS. 카타르 해역.', eventId: 'd12-p5' },
|
||||
];
|
||||
|
||||
194
frontend/src/data/meEnergyHazardFacilities.ts
Normal file
194
frontend/src/data/meEnergyHazardFacilities.ts
Normal file
@ -0,0 +1,194 @@
|
||||
// Middle East Energy & Hazard Facilities (OSINT + OpenStreetMap)
|
||||
|
||||
export type FacilitySubType =
|
||||
| 'power' | 'wind' | 'nuclear' | 'thermal' // energy
|
||||
| 'petrochem' | 'lng' | 'oil_tank' | 'haz_port'; // hazard
|
||||
|
||||
export interface EnergyHazardFacility {
|
||||
id: string;
|
||||
name: string;
|
||||
nameKo: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
country: string; // ISO-2
|
||||
countryKey: string; // overseas layer key prefix (us, il, ir, ae, sa, om, qa, kw, iq, bh)
|
||||
category: 'energy' | 'hazard';
|
||||
subType: FacilitySubType;
|
||||
capacityMW?: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SUB_TYPE_META: Record<FacilitySubType, { label: string; color: string; icon: string }> = {
|
||||
power: { label: '발전소', color: '#a855f7', icon: '⚡' },
|
||||
wind: { label: '풍력단지', color: '#22d3ee', icon: '🌬' },
|
||||
nuclear: { label: '원자력발전소', color: '#f59e0b', icon: '☢' },
|
||||
thermal: { label: '화력발전소', color: '#64748b', icon: '🏭' },
|
||||
petrochem: { label: '석유화학단지', color: '#f97316', icon: '🛢' },
|
||||
lng: { label: 'LNG저장기지', color: '#0ea5e9', icon: '❄' },
|
||||
oil_tank: { label: '유류저장탱크', color: '#eab308', icon: '🛢' },
|
||||
haz_port: { label: '위험물항만하역시설', color: '#dc2626', icon: '⚠' },
|
||||
};
|
||||
|
||||
// layer key -> subType mapping
|
||||
export function layerKeyToSubType(key: string): FacilitySubType | null {
|
||||
if (key.endsWith('Power')) return 'power';
|
||||
if (key.endsWith('Wind')) return 'wind';
|
||||
if (key.endsWith('Nuclear')) return 'nuclear';
|
||||
if (key.endsWith('Thermal')) return 'thermal';
|
||||
if (key.endsWith('Petrochem')) return 'petrochem';
|
||||
if (key.endsWith('Lng')) return 'lng';
|
||||
if (key.endsWith('OilTank')) return 'oil_tank';
|
||||
if (key.endsWith('HazPort')) return 'haz_port';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function layerKeyToCountry(key: string): string | null {
|
||||
const m = key.match(/^(us|il|ir|ae|sa|om|qa|kw|iq|bh)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
export const ME_ENERGY_HAZARD_FACILITIES: EnergyHazardFacility[] = [
|
||||
// ════════════════════════════════════════════
|
||||
// 🇺🇸 미국 (중동 주둔 시설 + 에너지 인프라)
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'US-E01', name: 'Al Udeid Power Plant', nameKo: '알우데이드 발전소', lat: 25.1175, lng: 51.3150, country: 'US', countryKey: 'us', category: 'energy', subType: 'power', capacityMW: 200, description: '미군 알우데이드 기지 전용 발전시설' },
|
||||
{ id: 'US-H01', name: 'Bahrain NAVSUP Fuel Depot', nameKo: '바레인 미해군 유류저장소', lat: 26.2361, lng: 50.6036, country: 'US', countryKey: 'us', category: 'hazard', subType: 'oil_tank', description: 'NSA Bahrain 유류 보급 시설' },
|
||||
{ id: 'US-H02', name: 'Jebel Ali US Navy Fuel Terminal', nameKo: '제벨알리 미해군 연료터미널', lat: 25.0100, lng: 55.0600, country: 'US', countryKey: 'us', category: 'hazard', subType: 'haz_port', description: '미 제5함대 연료 보급 항만' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇱 이스라엘
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'IL-E01', name: 'Orot Rabin Power Station', nameKo: '오롯 라빈 화력발전소', lat: 32.3915, lng: 34.8610, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2590, description: '이스라엘 최대 석탄/가스 복합 발전소 (하데라)' },
|
||||
{ id: 'IL-E02', name: 'Rutenberg Power Station', nameKo: '루텐베르그 화력발전소', lat: 31.6200, lng: 34.5300, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 2250, description: '아슈켈론 석탄 화력발전소' },
|
||||
{ id: 'IL-E03', name: 'Eshkol Power Station', nameKo: '에쉬콜 발전소', lat: 31.7940, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'energy', subType: 'thermal', capacityMW: 1096, description: '아슈도드 해안 천연가스 복합화력 (IEC 운영)' },
|
||||
{ id: 'IL-E04', name: 'Hagit Power Station', nameKo: '하깃 발전소', lat: 32.5600, lng: 35.0800, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 600, description: '북부 가스터빈 발전소' },
|
||||
{ id: 'IL-E05', name: 'Dimona Nuclear Research Center', nameKo: '디모나 원자력연구센터', lat: 31.0014, lng: 35.1467, country: 'IL', countryKey: 'il', category: 'energy', subType: 'nuclear', description: '네게브 원자력연구시설 (IRR-2)' },
|
||||
{ id: 'IL-E06', name: 'Ashalim Solar Power Station', nameKo: '아샬림 태양광발전소', lat: 31.1300, lng: 34.6600, country: 'IL', countryKey: 'il', category: 'energy', subType: 'power', capacityMW: 310, description: '네게브 사막 CSP+PV 복합 발전' },
|
||||
// Hazard
|
||||
{ id: 'IL-H01', name: 'Haifa Bay Petrochemical Complex', nameKo: '하이파만 석유화학단지', lat: 32.8100, lng: 35.0500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'petrochem', description: 'Oil Refineries Ltd. + Bazan Group 정유/석유화학 단지' },
|
||||
{ id: 'IL-H02', name: 'Ashdod Oil Terminal', nameKo: '아시도드 유류터미널', lat: 31.8200, lng: 34.6350, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: 'EAPC 원유 수입 터미널 + 저장탱크' },
|
||||
{ id: 'IL-H03', name: 'Ashkelon Desalination & Energy Hub', nameKo: '아슈켈론 에너지허브', lat: 31.6100, lng: 34.5400, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'haz_port', description: '해수담수화 + LNG 수입 터미널' },
|
||||
{ id: 'IL-H04', name: 'Eilat-Ashkelon Pipeline Terminal', nameKo: 'EAPC 에일라트 터미널', lat: 29.5500, lng: 34.9500, country: 'IL', countryKey: 'il', category: 'hazard', subType: 'oil_tank', description: '홍해 원유 수입 파이프라인 터미널' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇷 이란 (Wikipedia + OSINT 기반)
|
||||
// 총 설치용량 ~85,000MW, 화력 95%+, 수력 ~12,000MW
|
||||
// ════════════════════════════════════════════
|
||||
// ── 화력발전소 (Thermal) ──
|
||||
{ id: 'IR-E01', name: 'Damavand Power Plant', nameKo: '다마반드 발전소', lat: 35.5200, lng: 51.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2900, description: '이란 최대 화력발전소, 테헤란 남동 50km (가스복합)' },
|
||||
{ id: 'IR-E02', name: 'Shahid Salimi (Neka) Power Plant', nameKo: '샤히드 살리미(네카) 발전소', lat: 36.6500, lng: 53.3300, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2214, description: '마잔다란주, 이란 2위 화력 (카스피해 연안)' },
|
||||
{ id: 'IR-E03', name: 'Shahid Rajaee Combined Cycle', nameKo: '샤히드 라자이 복합화력', lat: 36.3700, lng: 49.9900, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 2042, description: '가즈빈주, 이란 3위 복합화력' },
|
||||
{ id: 'IR-E04', name: 'Ramin Steam Power Plant', nameKo: '라민 증기화력발전소', lat: 31.3100, lng: 48.7400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1890, description: '후제스탄주 아바즈 인근 증기터빈' },
|
||||
{ id: 'IR-E05', name: 'Shahid Montazeri Power Plant', nameKo: '샤히드 몬타제리 발전소', lat: 32.6500, lng: 51.6800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1600, description: '이스파한, 1984년 가동 개시' },
|
||||
{ id: 'IR-E06', name: 'Parand Combined Cycle', nameKo: '파란드 복합화력', lat: 35.4700, lng: 51.0100, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1536, description: '테헤란 서남부 복합화력' },
|
||||
{ id: 'IR-E07', name: 'Tabriz Thermal Power Plant', nameKo: '타브리즈 화력발전소', lat: 38.0600, lng: 46.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1386, description: '동아제르바이잔주' },
|
||||
{ id: 'IR-E08', name: 'Bandar Abbas Power Plant', nameKo: '반다르아바스 발전소', lat: 27.2000, lng: 56.2500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1057, description: '호르무즈해협 연안' },
|
||||
{ id: 'IR-E09', name: 'Besat Power Plant', nameKo: '베사트 발전소', lat: 35.8300, lng: 50.9800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '테헤란 남부 가스터빈' },
|
||||
{ id: 'IR-E10', name: 'Tous Power Plant', nameKo: '투스 발전소', lat: 36.3100, lng: 59.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1470, description: '마슈하드 인근 복합화력' },
|
||||
{ id: 'IR-E11', name: 'Fars (Shahid Dastjerdi) Power Plant', nameKo: '파르스 발전소', lat: 29.6000, lng: 52.5000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1028, description: '시라즈 인근 가스복합' },
|
||||
{ id: 'IR-E12', name: 'Hormozgan Power Plant', nameKo: '호르모즈간 발전소', lat: 27.1800, lng: 56.3000, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 906, description: '호르모즈간주 가스복합' },
|
||||
{ id: 'IR-E13', name: 'Shahid Mofateh Power Plant', nameKo: '샤히드 모파테 발전소', lat: 34.7700, lng: 48.5200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 1000, description: '하메단주 복합화력' },
|
||||
{ id: 'IR-E14', name: 'Kerman Combined Cycle', nameKo: '케르만 복합화력', lat: 30.2600, lng: 57.0700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 928, description: '케르만주 복합화력' },
|
||||
{ id: 'IR-E15', name: 'Yazd Combined Cycle', nameKo: '야즈드 복합화력', lat: 31.9000, lng: 54.3700, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'thermal', capacityMW: 948, description: '야즈드주 가스복합' },
|
||||
// ── 수력발전소 (Hydro) ──
|
||||
{ id: 'IR-E16', name: 'Karun-3 (Shahid Rajaee) Dam', nameKo: '카룬-3 수력발전소', lat: 31.8055, lng: 50.0893, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2280, description: '이란 최대 수력, 후제스탄주 이제 SE 28km, 8기' },
|
||||
{ id: 'IR-E17', name: 'Shahid Abbaspour (Karun-1) Dam', nameKo: '카룬-1 (샤히드 아바스푸르) 수력', lat: 32.0519, lng: 49.6069, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '후제스탄주 마스제드솔레이만 NE 50km' },
|
||||
{ id: 'IR-E18', name: 'Karun-4 Dam', nameKo: '카룬-4 수력발전소', lat: 31.5969, lng: 50.4712, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 1000, description: '카룬강 상류, 2011년 가동' },
|
||||
{ id: 'IR-E19', name: 'Dez Dam Hydropower', nameKo: '데즈댐 수력발전소', lat: 32.6053, lng: 48.4640, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 520, description: '후제스탄주 안디메쉬크 NE 20km, 8기' },
|
||||
{ id: 'IR-E20', name: 'Masjed Soleiman Dam', nameKo: '마스제드솔레이만 수력', lat: 32.0300, lng: 49.2800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 2000, description: '카룬강 하류, 대형 아치댐' },
|
||||
// ── 원자력/핵시설 (Nuclear) ──
|
||||
{ id: 'IR-E21', name: 'Bushehr Nuclear Power Plant', nameKo: '부셰르 원자력발전소', lat: 28.8267, lng: 50.8867, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 915, description: '이란 유일 상업 원전 (VVER-1000), 1995 러시아 계약' },
|
||||
{ id: 'IR-E22', name: 'Natanz Enrichment Facility', nameKo: '나탄즈 우라늄농축시설', lat: 33.7250, lng: 51.7267, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '주요 원심분리기 농축시설 (지하)' },
|
||||
{ id: 'IR-E23', name: 'Fordow Enrichment Facility', nameKo: '포르도 우라늄농축시설', lat: 34.8800, lng: 51.5800, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '지하 농축시설 (FFEP, 쿰 인근 산속)' },
|
||||
{ id: 'IR-E24', name: 'Isfahan Nuclear Technology Center', nameKo: '이스파한 핵기술센터 (UCF)', lat: 32.7200, lng: 51.7200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: '우라늄전환시설 + 연구용 원자로' },
|
||||
{ id: 'IR-E25', name: 'Arak Heavy Water Reactor (IR-40)', nameKo: '아라크 중수로', lat: 34.0400, lng: 49.2400, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', description: 'IR-40 중수 연구용 원자로 (마르카지주)' },
|
||||
{ id: 'IR-E26', name: 'Darkhovin Nuclear Power Plant', nameKo: '다르코빈 원자력발전소', lat: 31.3700, lng: 48.3200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'nuclear', capacityMW: 360, description: '이란 자체 건설 원전 (2007 착공, 후제스탄주)' },
|
||||
// ── 풍력 (Wind) ──
|
||||
{ id: 'IR-E27', name: 'Manjil-Rudbar Wind Farm', nameKo: '만질-루드바르 풍력단지', lat: 36.7400, lng: 49.4200, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 101, description: '길란주, 이란 최대 풍력 (2003 가동)' },
|
||||
{ id: 'IR-E28', name: 'Binaloud Wind Farm', nameKo: '비날루드 풍력단지', lat: 36.2200, lng: 58.7500, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'wind', capacityMW: 28, description: '라자비호라산주 니샤푸르 인근, 43기 x 660kW' },
|
||||
// ── 태양광 (Solar) ──
|
||||
{ id: 'IR-E29', name: 'Zarand Solar Power Plant', nameKo: '자란드 태양광발전소', lat: 30.8100, lng: 56.5600, country: 'IR', countryKey: 'ir', category: 'energy', subType: 'power', capacityMW: 10, description: '케르만주 태양광 시범단지' },
|
||||
// Hazard
|
||||
{ id: 'IR-H01', name: 'South Pars Gas Complex (Assaluyeh)', nameKo: '사우스파르스 가스단지 (아살루예)', lat: 27.4800, lng: 52.6100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '세계 최대 가스전 육상 처리시설 (20+ 페이즈)' },
|
||||
{ id: 'IR-H02', name: 'Kharg Island Oil Terminal', nameKo: '하르그섬 원유터미널', lat: 29.2300, lng: 50.3100, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '이란 원유 수출의 90% 처리 (저장 2,800만 배럴)' },
|
||||
{ id: 'IR-H03', name: 'Bandar Imam Khomeini Petrochemical', nameKo: '반다르 이맘호메이니 석유화학', lat: 30.4300, lng: 49.0800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: 'Mahshahr 특별경제구역 석유화학단지' },
|
||||
{ id: 'IR-H04', name: 'Tombak LNG Terminal', nameKo: '톰박 LNG터미널', lat: 27.5200, lng: 52.5500, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'lng', description: 'Iran LNG 수출 터미널 (건설중)' },
|
||||
{ id: 'IR-H05', name: 'Bandar Abbas Oil Refinery', nameKo: '반다르아바스 정유소', lat: 27.2100, lng: 56.2800, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'petrochem', description: '일 320,000배럴 정유시설' },
|
||||
{ id: 'IR-H06', name: 'Lavan Island Oil Terminal', nameKo: '라반섬 원유터미널', lat: 26.8100, lng: 53.3600, country: 'IR', countryKey: 'ir', category: 'hazard', subType: 'oil_tank', description: '페르시아만 원유 저장/선적 시설' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇦🇪 UAE
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'AE-E01', name: 'Barakah Nuclear Power Plant', nameKo: '바라카 원자력발전소', lat: 23.9592, lng: 52.2567, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'nuclear', capacityMW: 5600, description: '아랍 최초 상업 원전 (APR-1400 x4)' },
|
||||
{ id: 'AE-E02', name: 'Jebel Ali Power & Desalination', nameKo: '제벨알리 발전/담수', lat: 25.0200, lng: 55.1100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 8695, description: '세계 최대 복합 발전/담수 단지' },
|
||||
{ id: 'AE-E03', name: 'Shams Solar Power Station', nameKo: '샴스 태양광발전소', lat: 23.5800, lng: 53.7100, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'power', capacityMW: 100, description: '아부다비 CSP 태양열 발전' },
|
||||
{ id: 'AE-E04', name: 'Hassyan Clean Coal Power Plant', nameKo: '하시안 청정석탄발전소', lat: 24.9600, lng: 55.0300, country: 'AE', countryKey: 'ae', category: 'energy', subType: 'thermal', capacityMW: 2400, description: '두바이 석탄→가스 전환 중' },
|
||||
// Hazard
|
||||
{ id: 'AE-H01', name: 'Ruwais Industrial Complex (ADNOC)', nameKo: '루와이스 산업단지 (ADNOC)', lat: 24.1100, lng: 52.7300, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'petrochem', description: 'ADNOC 정유/석유화학 통합단지 (세계 최대급)' },
|
||||
{ id: 'AE-H02', name: 'Das Island LNG Terminal', nameKo: '다스섬 LNG터미널', lat: 25.1600, lng: 52.8700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'lng', description: 'ADGAS LNG 수출 터미널 (연 570만톤)' },
|
||||
{ id: 'AE-H03', name: 'Fujairah Oil Terminal (FOSC)', nameKo: '푸자이라 유류터미널', lat: 25.1200, lng: 56.3400, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'oil_tank', description: '세계 3대 벙커링 허브 (저장 1,400만m3)' },
|
||||
{ id: 'AE-H04', name: 'Jebel Ali Free Zone Port', nameKo: '제벨알리 자유무역항', lat: 25.0000, lng: 55.0700, country: 'AE', countryKey: 'ae', category: 'hazard', subType: 'haz_port', description: '중동 최대 항만 (위험물 취급)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇸🇦 사우디아라비아
|
||||
// ════════════════════════════════════════════
|
||||
// Energy
|
||||
{ id: 'SA-E01', name: 'Shoaiba Power & Desalination', nameKo: '쇼아이바 발전/담수', lat: 20.7000, lng: 39.5100, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 5600, description: '홍해 연안 세계 최대급 복합 발전/담수' },
|
||||
{ id: 'SA-E02', name: 'Rabigh Power Plant', nameKo: '라비그 발전소', lat: 22.8000, lng: 39.0200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2100, description: '홍해 연안 가스복합 발전소' },
|
||||
{ id: 'SA-E03', name: 'Dumat Al Jandal Wind Farm', nameKo: '두마트알잔달 풍력단지', lat: 29.8100, lng: 39.8700, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'wind', capacityMW: 400, description: '중동 최대 풍력단지' },
|
||||
{ id: 'SA-E04', name: 'Jubail IWPP', nameKo: '주바일 발전소', lat: 27.0200, lng: 49.6200, country: 'SA', countryKey: 'sa', category: 'energy', subType: 'thermal', capacityMW: 2745, description: '동부 산업도시 복합 발전' },
|
||||
// Hazard
|
||||
{ id: 'SA-H01', name: 'Ras Tanura Oil Terminal', nameKo: '라스타누라 원유터미널', lat: 26.6400, lng: 50.1600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 해상 원유 선적 시설 (일 600만 배럴)' },
|
||||
{ id: 'SA-H02', name: 'Jubail Industrial City (SABIC)', nameKo: '주바일 산업단지 (SABIC)', lat: 27.0000, lng: 49.6500, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '세계 최대 석유화학 산업단지' },
|
||||
{ id: 'SA-H03', name: 'Yanbu Industrial City', nameKo: '얀부 산업단지', lat: 23.9600, lng: 38.2400, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'petrochem', description: '홍해 연안 정유/석유화학 단지' },
|
||||
{ id: 'SA-H04', name: 'Ras Al-Khair LNG Import', nameKo: '라스알카이르 LNG', lat: 27.4800, lng: 49.2600, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'lng', description: 'LNG 수입/가스화 터미널' },
|
||||
{ id: 'SA-H05', name: 'Abqaiq Oil Processing', nameKo: '아브카이크 원유처리시설', lat: 25.9400, lng: 49.6800, country: 'SA', countryKey: 'sa', category: 'hazard', subType: 'oil_tank', description: '세계 최대 원유 안정화 시설 (2019 공격 대상)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇴🇲 오만
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'OM-E01', name: 'Barka Power & Desalination', nameKo: '바르카 발전/담수', lat: 23.6800, lng: 57.8700, country: 'OM', countryKey: 'om', category: 'energy', subType: 'thermal', capacityMW: 2007, description: 'GDF Suez 운영 복합발전' },
|
||||
{ id: 'OM-E02', name: 'Dhofar Wind Farm', nameKo: '도파르 풍력단지', lat: 17.0200, lng: 54.1000, country: 'OM', countryKey: 'om', category: 'energy', subType: 'wind', capacityMW: 50, description: 'GCC 최초 대형 풍력단지' },
|
||||
{ id: 'OM-H01', name: 'Sohar Industrial Port', nameKo: '소하르 산업항', lat: 24.3600, lng: 56.7400, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'petrochem', description: '정유소+석유화학+알루미늄 제련단지' },
|
||||
{ id: 'OM-H02', name: 'Qalhat LNG Terminal', nameKo: '칼하트 LNG터미널', lat: 22.9200, lng: 59.3700, country: 'OM', countryKey: 'om', category: 'hazard', subType: 'lng', description: 'Oman LNG 수출 (연 1,060만톤)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇶🇦 카타르
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'QA-E01', name: 'Ras Laffan Power Plant', nameKo: '라스라판 발전소', lat: 25.9100, lng: 51.5500, country: 'QA', countryKey: 'qa', category: 'energy', subType: 'thermal', capacityMW: 2730, description: '카타르 최대 발전소' },
|
||||
{ id: 'QA-H01', name: 'Ras Laffan Industrial City', nameKo: '라스라판 산업단지', lat: 25.9200, lng: 51.5300, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'lng', description: '세계 최대 LNG 수출기지 (QatarEnergy, 연 7,700만톤)' },
|
||||
{ id: 'QA-H02', name: 'Mesaieed Industrial City', nameKo: '메사이드 산업단지', lat: 24.9900, lng: 51.5600, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'petrochem', description: 'QatarEnergy 정유/석유화학/비료 단지' },
|
||||
{ id: 'QA-H03', name: 'Dukhan Oil Field Terminal', nameKo: '두칸 유전터미널', lat: 25.4300, lng: 50.7700, country: 'QA', countryKey: 'qa', category: 'hazard', subType: 'oil_tank', description: '서부 해안 육상 유전 터미널' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇰🇼 쿠웨이트
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'KW-E01', name: 'Az-Zour Power Plant', nameKo: '아즈주르 발전소', lat: 28.7200, lng: 48.3800, country: 'KW', countryKey: 'kw', category: 'energy', subType: 'thermal', capacityMW: 4800, description: '쿠웨이트 최대 발전/담수' },
|
||||
{ id: 'KW-H01', name: 'Mina Al Ahmadi Refinery', nameKo: '미나알아흐마디 정유소', lat: 29.0600, lng: 48.1500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'petrochem', description: 'KNPC 운영 (일 466,000배럴)' },
|
||||
{ id: 'KW-H02', name: 'Az-Zour LNG Import Terminal', nameKo: '아즈주르 LNG터미널', lat: 28.7100, lng: 48.3500, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'lng', description: '쿠웨이트 LNG 수입 터미널' },
|
||||
{ id: 'KW-H03', name: 'Mina Abdullah Oil Tank Farm', nameKo: '미나압둘라 유류저장기지', lat: 29.0000, lng: 48.1700, country: 'KW', countryKey: 'kw', category: 'hazard', subType: 'oil_tank', description: '남부 원유 저장/선적' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇮🇶 이라크
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'IQ-E01', name: 'Basra Gas Power Plant', nameKo: '바스라 가스발전소', lat: 30.5100, lng: 47.7800, country: 'IQ', countryKey: 'iq', category: 'energy', subType: 'thermal', capacityMW: 1500, description: '남부 이라크 최대 발전소' },
|
||||
{ id: 'IQ-H01', name: 'Basra Oil Terminal (ABOT)', nameKo: '알바스라 원유터미널', lat: 29.6800, lng: 48.8000, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 원유 수출의 85% (페르시아만)' },
|
||||
{ id: 'IQ-H02', name: 'Khor Al-Zubair Port', nameKo: '코르알주바이르 항', lat: 30.1700, lng: 47.8700, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'haz_port', description: '이라크 주요 위험물 하역항' },
|
||||
{ id: 'IQ-H03', name: 'Rumaila Oil Field', nameKo: '루마일라 유전', lat: 30.6300, lng: 47.4300, country: 'IQ', countryKey: 'iq', category: 'hazard', subType: 'oil_tank', description: '이라크 최대 유전 (일 150만 배럴)' },
|
||||
|
||||
// ════════════════════════════════════════════
|
||||
// 🇧🇭 바레인
|
||||
// ════════════════════════════════════════════
|
||||
{ id: 'BH-E01', name: 'Al Dur Power & Water Plant', nameKo: '알두르 발전/담수', lat: 25.9400, lng: 50.6200, country: 'BH', countryKey: 'bh', category: 'energy', subType: 'thermal', capacityMW: 1234, description: '바레인 최대 발전소' },
|
||||
{ id: 'BH-H01', name: 'Sitra Oil Refinery (BAPCO)', nameKo: '시트라 정유소 (BAPCO)', lat: 26.1500, lng: 50.6100, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'petrochem', description: '바레인 유일 정유시설 (일 267,000배럴)' },
|
||||
{ id: 'BH-H02', name: 'Khalifa Bin Salman Port', nameKo: '칼리파빈살만항', lat: 26.0200, lng: 50.5500, country: 'BH', countryKey: 'bh', category: 'hazard', subType: 'haz_port', description: '바레인 주요 무역항 (위험물 하역)' },
|
||||
];
|
||||
|
||||
// Helper: filter by country key and subType
|
||||
export function filterFacilities(countryKey: string, subType?: FacilitySubType): EnergyHazardFacility[] {
|
||||
return ME_ENERGY_HAZARD_FACILITIES.filter(f =>
|
||||
f.countryKey === countryKey && (subType ? f.subType === subType : true)
|
||||
);
|
||||
}
|
||||
@ -1429,6 +1429,48 @@ export const sampleEvents: GeoEvent[] = [
|
||||
label: 'UN 안보리 — 호르무즈 해협 긴급회의 소집',
|
||||
description: 'UN 안보리, 호르무즈 해협 상선 피격 관련 긴급회의 소집. 중국·러시아 즉각 휴전 촉구, 미국 항행자유 강조.',
|
||||
},
|
||||
|
||||
// ═══ D+20 (2026-03-21) 나탄즈-디모나 핵시설 교차공격 ═══
|
||||
{
|
||||
id: 'd20-il1', timestamp: T0 + 20 * DAY + 4 * HOUR,
|
||||
lat: 33.7250, lng: 51.7267, type: 'strike',
|
||||
label: '나탄즈 — 이스라엘 핵시설 공습',
|
||||
description: 'IAF, 이란 나탄즈 우라늄 농축시설 정밀 타격. 이란 원자력청 "나탄즈 농축시설이 공격 표적이 됐다" 확인. IAEA 방사능 유출 미확인.',
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Natanz_nuclear.jpg/320px-Natanz_nuclear.jpg',
|
||||
imageCaption: '나탄즈 핵시설 위성사진 (Wikimedia Commons)',
|
||||
},
|
||||
{
|
||||
id: 'd20-ir-assess', timestamp: T0 + 20 * DAY + 6 * HOUR,
|
||||
lat: 33.7250, lng: 51.7267, type: 'alert',
|
||||
label: '나탄즈 — 이란 방사능 조사 착수',
|
||||
description: '이란 원자력안전센터, 나탄즈 시설 인근 방사성 오염물질 배출 가능성 정밀 기술 조사. "현재까지 방사성 물질 누출 보고 없음, 인근 주민 위협 없음" 발표.',
|
||||
},
|
||||
{
|
||||
id: 'd20-ir1', timestamp: T0 + 20 * DAY + 10 * HOUR,
|
||||
lat: 31.0014, lng: 35.1467, type: 'strike',
|
||||
label: '디모나 — 이란 보복 미사일 공격',
|
||||
description: 'IRGC, 나탄즈 피격 보복으로 이스라엘 디모나 핵연구센터 겨냥 탄도미사일 발사. 이스라엘 방공 요격 실패, 최소 30명 이상 사상자 발생. 핵연구센터 직접 피해는 미확인.',
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e0/Negev_Nuclear_Research_Center.jpg/320px-Negev_Nuclear_Research_Center.jpg',
|
||||
imageCaption: '디모나 네게브 핵연구센터 (Wikimedia Commons)',
|
||||
},
|
||||
{
|
||||
id: 'd20-il-def', timestamp: T0 + 20 * DAY + 10.5 * HOUR,
|
||||
lat: 31.0014, lng: 35.1467, type: 'alert',
|
||||
label: '디모나 — 요격 실패 조사 착수',
|
||||
description: '이스라엘군, 이란발 탄도미사일 요격 실패 경위 조사 착수. 요격 미사일이 목표물 격추에 실패, 미사일이 마을에 충돌. 막대한 재산 피해.',
|
||||
},
|
||||
{
|
||||
id: 'd20-iaea', timestamp: T0 + 20 * DAY + 12 * HOUR,
|
||||
lat: 48.2082, lng: 16.3738, type: 'alert',
|
||||
label: 'IAEA — 양측 핵시설 상황 파악 중',
|
||||
description: 'IAEA, 나탄즈 및 디모나 핵시설 상황 파악 중. 그로시 사무총장 "핵사고 위험 회피 위해 군사행동 자제 거듭 촉구". 양측 시설 모두 비정상 방사능 수치 미감지.',
|
||||
},
|
||||
{
|
||||
id: 'd20-p1', timestamp: T0 + 20 * DAY + 14 * HOUR,
|
||||
lat: 38.8977, lng: -77.0365, type: 'alert',
|
||||
label: '워싱턴 — 미국 핵시설 공격 우려 성명',
|
||||
description: '미 국무부, 이란의 디모나 공격에 강력 규탄. "핵시설 겨냥 군사행동은 국제법 중대 위반" 경고. 이스라엘 방공체계 지원 강화 발표.',
|
||||
},
|
||||
];
|
||||
|
||||
// 24시간 동안 10분 간격 센서 데이터 생성
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -145,7 +145,8 @@ export interface LayerVisibility {
|
||||
meFacilities: boolean;
|
||||
militaryOnly: boolean;
|
||||
overseasUS: boolean;
|
||||
overseasUK: boolean;
|
||||
overseasIsrael: boolean;
|
||||
[key: string]: boolean;
|
||||
overseasIran: boolean;
|
||||
overseasUAE: boolean;
|
||||
overseasSaudi: boolean;
|
||||
|
||||
@ -110,6 +110,21 @@ export default defineConfig(({ mode }): UserConfig => ({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ollama': {
|
||||
target: 'http://localhost:11434',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/ollama/, ''),
|
||||
},
|
||||
'/api/gtts': {
|
||||
target: 'https://translate.google.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/gtts/, '/translate_tts'),
|
||||
secure: true,
|
||||
headers: {
|
||||
'Referer': 'https://translate.google.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user