import { useState, useMemo } from 'react'
import { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaflet'
import L from 'leaflet'
import type { LatLngExpression } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
import { mockVessels, VESSEL_LEGEND, type Vessel } from '../../../data/vesselMockData'
// Mock incident data (HTML 참고 6건)
const mockIncidents: Incident[] = [
{
id: '1', name: '여수항 유류유출', status: 'active',
date: '2026-02-18', time: '15:01', region: '남해청', office: '여수서',
location: { lat: 34.74, lon: 127.68 },
causeType: '충돌/좌초', oilType: 'BUNKER_C', prediction: '예측완료', mediaCount: 4,
},
{
id: '2', name: '군산항 인근 오염', status: 'investigating',
date: '2026-02-18', time: '13:01', region: '서해청', office: '군산서',
location: { lat: 35.97, lon: 126.72 },
causeType: '원인미상', mediaCount: 2,
},
{
id: '3', name: '통영 해역 기름오염', status: 'active',
date: '2026-02-18', time: '13:31', region: '남해청', office: '통영서',
location: { lat: 34.85, lon: 128.43 },
causeType: '화물/하역', oilType: 'DIESEL', prediction: '예측완료', mediaCount: 3,
},
{
id: '4', name: '동해항 유출사고', status: 'closed',
date: '2026-02-15', time: '13:30', region: '동해청', office: '동해서',
location: { lat: 37.49, lon: 129.11 },
causeType: '충돌/좌초', oilType: 'HEAVY_FUEL_OIL', prediction: '예측완료', mediaCount: 5,
},
{
id: '5', name: '사곡해수욕장 해양오염', status: 'investigating',
date: '2026-02-12', time: '09:20', region: '남해청', office: '완도서',
location: { lat: 34.32, lon: 126.76 },
causeType: '원인미상', mediaCount: 1,
},
{
id: '6', name: '제주항 부두 유출', status: 'closed',
date: '2026-02-10', time: '11:00', region: '제주청', office: '제주서',
location: { lat: 33.51, lon: 126.53 },
causeType: '항만/배관', oilType: 'DIESEL', mediaCount: 2,
},
]
/* ── Vessel DivIcon ──────────────────────────────── */
function makeVesselIcon(v: Vessel) {
const isAccident = v.status.includes('사고')
return L.divIcon({
className: '',
html: `
`,
iconSize: [10, 12],
iconAnchor: [5, 6],
})
}
/* ════════════════════════════════════════════════════
IncidentsView
════════════════════════════════════════════════════ */
export function IncidentsView() {
const [selectedIncidentId, setSelectedIncidentId] = useState(mockIncidents[0].id)
const [selectedVessel, setSelectedVessel] = useState(null)
const [detailVessel, setDetailVessel] = useState(null)
// Analysis view mode
const [viewMode, setViewMode] = useState('overlay')
const [analysisActive, setAnalysisActive] = useState(false)
const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([])
const mapCenter: LatLngExpression = [35.0, 127.8]
const selectedIncident = mockIncidents.find((i) => i.id === selectedIncidentId) ?? null
const vesselIcons = useMemo(() => mockVessels.map((v) => makeVesselIcon(v)), [])
const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => {
if (sections.length === 0) return
const tags: { icon: string; label: string; color: string }[] = []
sections.forEach(s => {
if (s.key === 'oil') tags.push({ icon: '🛢', label: '유출유', color: '#f97316' })
if (s.key === 'hns') tags.push({ icon: '🧪', label: 'HNS', color: '#a855f7' })
if (s.key === 'rsc') tags.push({ icon: '🚨', label: '구난', color: '#06b6d4' })
})
if (sensitiveCount > 0) tags.push({ icon: '🐟', label: `민감자원 ${sensitiveCount}건`, color: '#22c55e' })
setAnalysisTags(tags)
setAnalysisActive(true)
}
const handleCloseAnalysis = () => {
setAnalysisActive(false)
setAnalysisTags([])
}
const getMarkerColor = (s: string) => {
if (s === 'active') return { fill: '#ef4444', stroke: '#dc2626' }
if (s === 'investigating') return { fill: '#f59e0b', stroke: '#d97706' }
return { fill: '#6b7280', stroke: '#4b5563' }
}
const getStatusLabel = (s: string) =>
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : ''
return (
{/* Left Panel */}
{/* Center - Map + Analysis Views */}
{/* Analysis Bar (shown when analysis is active) */}
{analysisActive && (
🔬 통합 분석 비교
{selectedIncident?.name}
{analysisTags.map((t, i) => (
{t.icon} {t.label}
))}
{([
{ mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' },
{ mode: 'split2' as ViewMode, icon: '◫', label: '2분할' },
{ mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' },
]).map(v => (
))}
)}
{/* Map / Analysis Content Area */}
{/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && (
{mockIncidents.map((inc) => {
const c = getMarkerColor(inc.status)
const sel = selectedIncidentId === inc.id
return (
setSelectedIncidentId(inc.id) }}
>
{inc.name}
상태: {getStatusLabel(inc.status)}
일시: {inc.date} {inc.time}
관할: {inc.office}
{inc.causeType &&
원인: {inc.causeType}
}
{inc.prediction &&
{inc.prediction}
}
)
})}
{mockVessels.map((v, idx) => (
{ setSelectedVessel(v); setDetailVessel(null) } }}
/>
))}
{/* Overlay layers (shown on top of map when analysis active) */}
{analysisActive && viewMode === 'overlay' && (
{analysisTags.some(t => t.label === '유출유') && (
)}
{analysisTags.some(t => t.label === 'HNS') && (
)}
{analysisTags.some(t => t.label === '구난') && (
)}
)}
{/* AIS Live Badge */}
{/* Legend */}
사고 상태
{[{ c: '#ef4444', l: '대응중' }, { c: '#f59e0b', l: '조사중' }, { c: '#6b7280', l: '종료' }].map(s => (
))}
AIS 선박
{VESSEL_LEGEND.map(vl => (
))}
{/* Vessel Popup */}
{selectedVessel && !detailVessel && (
setSelectedVessel(null)}
onDetail={() => { setDetailVessel(selectedVessel); setSelectedVessel(null) }}
/>
)}
{detailVessel && (
setDetailVessel(null)} />
)}
)}
{/* ── 2분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split2' && (
{analysisTags[0] ? `${analysisTags[0].icon} ${analysisTags[0].label}` : '— 분석 결과를 선택하세요 —'}
{analysisTags[1] ? `${analysisTags[1].icon} ${analysisTags[1].label}` : '— 분석 결과를 선택하세요 —'}
)}
{/* ── 3분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split3' && (
{/* Oil spill */}
{/* HNS */}
{/* Emergency rescue */}
)}
{/* Decision Bar (shown at bottom when analysis is active) */}
{analysisActive && (
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')} 분석 결과 비교
)}
{/* Right Panel */}
)
}
/* ════════════════════════════════════════════════════
SplitPanelContent – 분할뷰 패널 내용
════════════════════════════════════════════════════ */
function SplitPanelContent({ tag, incident }: {
tag?: { icon: string; label: string; color: string }
incident: Incident | null
}) {
if (!tag) {
return (
R&D 분석 결과를 선택하세요
)
}
const mockData: Record = {
'유출유': {
title: '유출유 확산예측 결과',
model: 'KOSPS + OpenDrift · BUNKER-C 150kL',
items: [
{ label: '예측 시간', value: '72시간 (3일)' },
{ label: '최대 확산거리', value: '12.3 NM', color: '#f97316' },
{ label: '해안 도달 시간', value: '18시간 후', color: '#ef4444' },
{ label: '영향 해안선', value: '27.5 km' },
{ label: '풍화율', value: '32.4%' },
{ label: '잔존유량', value: '101.4 kL', color: '#f97316' },
],
summary: '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.',
},
'HNS': {
title: 'HNS 대기확산 결과',
model: 'ALOHA + PHAST · 톨루엔 5톤',
items: [
{ label: 'IDLH 범위', value: '1.2 km', color: '#ef4444' },
{ label: 'ERPG-2 범위', value: '2.8 km', color: '#f97316' },
{ label: 'ERPG-1 범위', value: '5.1 km', color: '#eab308' },
{ label: '풍향', value: 'SW → NE 방향' },
{ label: '대기 안정도', value: 'D등급 (중립)' },
{ label: '영향 인구', value: '약 2,400명', color: '#ef4444' },
],
summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.',
},
'구난': {
title: '긴급구난 SAR 결과',
model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션',
items: [
{ label: '95% 확률 범위', value: '8.5 NM²', color: '#06b6d4' },
{ label: '최적 탐색 경로', value: 'Sector Search' },
{ label: '예상 표류 속도', value: '1.8 kn' },
{ label: '표류 방향', value: 'NE (045°)' },
{ label: '생존 가능 시간', value: '36시간', color: '#ef4444' },
{ label: '필요 자산', value: '헬기 2 + 경비정 3', color: '#f97316' },
],
summary: '북동쪽 방향 표류 예상. 06:00 기준 최적 탐색 패턴: 섹터탐색(Sector Search).',
},
}
const data = mockData[tag.label] || mockData['유출유']
return (
<>
{/* Title card */}
{tag.icon} {data.title}
{data.model}
{incident && (
사고: {incident.name} · {incident.date} {incident.time}
)}
{/* Data items */}
{data.items.map((item, i) => (
{item.label}
{item.value}
))}
{/* Summary */}
💡 {data.summary}
{/* Mock visualization */}
>
)
}
/* ════════════════════════════════════════════════════
VesselPopupPanel – HTML vsl-popup 스타일 재현
════════════════════════════════════════════════════ */
function VesselPopupPanel({ vessel: v, onClose, onDetail }: {
vessel: Vessel; onClose: () => void; onDetail: () => void
}) {
const statusColor = v.status.includes('사고') ? '#ef4444' : '#22c55e'
const statusBg = v.status.includes('사고') ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.1)'
return (
{/* Header */}
{/* Ship Image */}
🚢
{/* Tags */}
{v.typS}
{v.status}
{/* Data rows */}
출항지
{v.depart}
입항지
{v.arrive}
{/* Buttons */}
)
}
function PopupRow({ label, value, accent, muted }: { label: string; value: string; accent?: boolean; muted?: boolean }) {
return (
{label}
{value}
)
}
/* ════════════════════════════════════════════════════
VesselDetailModal – 5탭 (상세/항해/제원/보험/위험물)
════════════════════════════════════════════════════ */
type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg'
const TAB_LABELS: { key: DetTab; label: string }[] = [
{ key: 'info', label: '상세정보' },
{ key: 'nav', label: '항해정보' },
{ key: 'spec', label: '선박제원' },
{ key: 'ins', label: '보험정보' },
{ key: 'dg', label: '위험물정보' },
]
function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) {
const [tab, setTab] = useState('info')
return (
{ if (e.target === e.currentTarget) onClose() }} style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(6px)',
}}>
{/* Header */}
{v.flag}
{v.name}
MMSI: {v.mmsi} · IMO: {v.imo}
✕
{/* Tabs */}
{TAB_LABELS.map(t => (
))}
{/* Body – scrollable */}
{tab === 'info' && }
{tab === 'nav' && }
{tab === 'spec' && }
{tab === 'ins' && }
{tab === 'dg' && }
)
}
/* ── shared section helpers ──────────────────────── */
function Sec({ title, borderColor, bgColor, badge, children }: {
title: string; borderColor?: string; bgColor?: string; badge?: React.ReactNode; children: React.ReactNode
}) {
return (
{title}{badge}
{children}
)
}
function Grid({ children }: { children: React.ReactNode }) {
return {children}
}
function Cell({ label, value, span, color }: { label: string; value: string; span?: boolean; color?: string }) {
return (
)
}
function StatusBadge({ label, color }: { label: string; color: string }) {
return (
{label}
)
}
/* ── Tab 0: 상세정보 ─────────────────────────────── */
function TabInfo({ v }: { v: Vessel }) {
return (
<>
{/* Ship image */}
🚢
|
|
|
|
|
|
|
|
|
|
>
)
}
/* ── Tab 1: 항해정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabNav(_props: { v: Vessel }) {
const hours = ['08', '09', '10', '11', '12', '13', '14']
const heights = [45, 60, 78, 82, 70, 85, 75]
const colors = ['rgba(34,197,94,.3)', 'rgba(34,197,94,.4)', 'rgba(59,130,246,.4)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.6)', 'rgba(6,182,212,.5)']
return (
<>
{hours.map((h, i) => (
))}
평균: 8.4 kn · 최대: 11.2 kn
>
)
}
/* ── Tab 2: 선박제원 ─────────────────────────────── */
function TabSpec({ v }: { v: Vessel }) {
return (
<>
|
|
|
|
|
|
|
|
|
|
|
|
🛢
{v.cargo.split('·')[0].trim()}
{v.cargo}
{v.cargo.includes('IMO') && (
위험
)}
>
)
}
/* ── Tab 3: 보험정보 ─────────────────────────────── */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function TabInsurance(_props: { v: Vessel }) {
return (
<>
|
|
|
}>
|
|
|
|
}>
|
|
|
|
}>
|
|
|
|
|
|
💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. 실시간 갱신 주기: 24시간
>
)
}
/* ── Tab 4: 위험물정보 ───────────────────────────── */
function TabDangerous({ v }: { v: Vessel }) {
return (
<>
PORT-MIS}>
|
|
|
|
|
|
|
|
|
|
화물창 2개이상 여부
✓
예
|
|
|
|
|
|
💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment 42-24) 기준.
>
)
}
function EmsRow({ icon, label, value, bg, bd }: { icon: string; label: string; value: string; bg: string; bd: string }) {
return (
)
}
function ActionBtn({ icon, label, bg, bd, fg }: { icon: string; label: string; bg: string; bd: string; fg: string }) {
return (
)
}