- frontend: ESLint 에러 86건 수정 (unused-vars, set-state-in-effect, static-components 등) - backend: simulation.ts req.params 타입 단언 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1039 lines
52 KiB
TypeScript
Executable File
1039 lines
52 KiB
TypeScript
Executable File
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 '../incidents/IncidentsLeftPanel'
|
||
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from '../incidents/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: `<div style="transform:rotate(${v.heading}deg);cursor:pointer">
|
||
<div style="width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-bottom:12px solid ${v.color};filter:drop-shadow(0 0 3px rgba(0,0,0,.5))${isAccident ? ';animation:pd 1.5s infinite' : ''}"></div>
|
||
</div>`,
|
||
iconSize: [10, 12],
|
||
iconAnchor: [5, 6],
|
||
})
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
IncidentsView
|
||
════════════════════════════════════════════════════ */
|
||
export function IncidentsView() {
|
||
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(mockIncidents[0].id)
|
||
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null)
|
||
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null)
|
||
|
||
// Analysis view mode
|
||
const [viewMode, setViewMode] = useState<ViewMode>('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 (
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* Left Panel */}
|
||
<IncidentsLeftPanel
|
||
incidents={mockIncidents}
|
||
selectedIncidentId={selectedIncidentId}
|
||
onIncidentSelect={setSelectedIncidentId}
|
||
/>
|
||
|
||
{/* Center - Map + Analysis Views */}
|
||
<div className="flex-1" style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
{/* Analysis Bar (shown when analysis is active) */}
|
||
{analysisActive && (
|
||
<div style={{
|
||
flexShrink: 0, height: 36, padding: '0 16px',
|
||
background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg1))',
|
||
borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
|
||
🔬 통합 분석 비교
|
||
</span>
|
||
<span style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||
{selectedIncident?.name}
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
{analysisTags.map((t, i) => (
|
||
<span key={i} style={{
|
||
padding: '2px 8px', borderRadius: 8, fontSize: 8, fontWeight: 600,
|
||
fontFamily: 'var(--fK)',
|
||
background: `${t.color}18`, border: `1px solid ${t.color}40`, color: t.color,
|
||
}}>{t.icon} {t.label}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
|
||
{([
|
||
{ mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' },
|
||
{ mode: 'split2' as ViewMode, icon: '◫', label: '2분할' },
|
||
{ mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' },
|
||
]).map(v => (
|
||
<button key={v.mode} onClick={() => setViewMode(v.mode)} style={{
|
||
padding: '3px 10px', borderRadius: 3, fontSize: 9, fontWeight: 600,
|
||
fontFamily: 'var(--fK)', cursor: 'pointer',
|
||
background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg3)',
|
||
border: viewMode === v.mode ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
|
||
color: viewMode === v.mode ? 'var(--cyan)' : 'var(--t3)',
|
||
}}>{v.icon} {v.label}</button>
|
||
))}
|
||
<button onClick={handleCloseAnalysis} style={{
|
||
padding: '3px 8px', borderRadius: 3, fontSize: 9, fontWeight: 600,
|
||
fontFamily: 'var(--fK)', cursor: 'pointer',
|
||
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.2)', color: '#f87171',
|
||
}}>✕ 닫기</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Map / Analysis Content Area */}
|
||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||
{/* Default Map (visible when not in analysis or in overlay mode) */}
|
||
{(!analysisActive || viewMode === 'overlay') && (
|
||
<div style={{ position: 'absolute', inset: 0 }}>
|
||
<MapContainer
|
||
center={mapCenter} zoom={7}
|
||
style={{ height: '100%', width: '100%', background: '#0a0e1a' }}
|
||
zoomControl={true}
|
||
>
|
||
<TileLayer
|
||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
/>
|
||
{mockIncidents.map((inc) => {
|
||
const c = getMarkerColor(inc.status)
|
||
const sel = selectedIncidentId === inc.id
|
||
return (
|
||
<CircleMarker key={inc.id}
|
||
center={[inc.location.lat, inc.location.lon]}
|
||
radius={sel ? 16 : 12}
|
||
pathOptions={{ fillColor: c.fill, fillOpacity: 0.8, color: sel ? '#06b6d4' : c.stroke, weight: sel ? 3 : 2 }}
|
||
eventHandlers={{ click: () => setSelectedIncidentId(inc.id) }}
|
||
>
|
||
<Popup>
|
||
<div className="text-center min-w-[180px]">
|
||
<div className="font-semibold text-text-1 mb-2">{inc.name}</div>
|
||
<div className="space-y-1 text-xs text-text-3">
|
||
<div>상태: {getStatusLabel(inc.status)}</div>
|
||
<div>일시: {inc.date} {inc.time}</div>
|
||
<div>관할: {inc.office}</div>
|
||
{inc.causeType && <div>원인: {inc.causeType}</div>}
|
||
{inc.prediction && <div className="text-primary-cyan">{inc.prediction}</div>}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</CircleMarker>
|
||
)
|
||
})}
|
||
{mockVessels.map((v, idx) => (
|
||
<Marker key={v.mmsi}
|
||
position={[v.lat, v.lng]}
|
||
icon={vesselIcons[idx]}
|
||
eventHandlers={{ click: () => { setSelectedVessel(v); setDetailVessel(null) } }}
|
||
/>
|
||
))}
|
||
</MapContainer>
|
||
|
||
{/* Overlay layers (shown on top of map when analysis active) */}
|
||
{analysisActive && viewMode === 'overlay' && (
|
||
<div style={{ position: 'absolute', inset: 0, zIndex: 500, pointerEvents: 'none' }}>
|
||
{analysisTags.some(t => t.label === '유출유') && (
|
||
<div style={{
|
||
position: 'absolute', top: '30%', left: '45%', width: 180, height: 120,
|
||
background: 'radial-gradient(ellipse, rgba(249,115,22,0.35) 0%, rgba(249,115,22,0.1) 50%, transparent 70%)',
|
||
borderRadius: '50%', transform: 'rotate(-15deg)',
|
||
}} />
|
||
)}
|
||
{analysisTags.some(t => t.label === 'HNS') && (
|
||
<div style={{
|
||
position: 'absolute', top: '25%', left: '50%', width: 150, height: 100,
|
||
background: 'radial-gradient(ellipse, rgba(168,85,247,0.3) 0%, rgba(168,85,247,0.08) 50%, transparent 70%)',
|
||
borderRadius: '50%', transform: 'rotate(20deg)',
|
||
}} />
|
||
)}
|
||
{analysisTags.some(t => t.label === '구난') && (
|
||
<div style={{
|
||
position: 'absolute', top: '35%', left: '42%', width: 200, height: 200,
|
||
border: '2px dashed rgba(6,182,212,0.4)', borderRadius: '50%',
|
||
}} />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* AIS Live Badge */}
|
||
<div style={{
|
||
position: 'absolute', top: 10, right: 10, zIndex: 500,
|
||
background: 'rgba(13,17,23,0.88)', border: '1px solid #30363d',
|
||
borderRadius: 8, padding: '8px 12px', backdropFilter: 'blur(8px)',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', animation: 'pd 1.5s infinite' }} />
|
||
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>AIS Live</span>
|
||
<span style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>MarineTraffic</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 10, fontSize: 9, fontFamily: 'var(--fM)' }}>
|
||
<div style={{ color: 'var(--t2)' }}>선박 <b style={{ color: 'var(--cyan)' }}>20</b></div>
|
||
<div style={{ color: 'var(--t2)' }}>사고 <b style={{ color: '#f87171' }}>6</b></div>
|
||
<div style={{ color: 'var(--t2)' }}>방제선 <b style={{ color: '#06b6d4' }}>2</b></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div style={{
|
||
position: 'absolute', bottom: 10, left: 10, zIndex: 500,
|
||
background: 'rgba(13,17,23,0.88)', border: '1px solid #30363d',
|
||
borderRadius: 8, padding: '8px 12px', backdropFilter: 'blur(8px)',
|
||
display: 'flex', flexDirection: 'column', gap: 6,
|
||
}}>
|
||
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)' }}>사고 상태</div>
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
{[{ c: '#ef4444', l: '대응중' }, { c: '#f59e0b', l: '조사중' }, { c: '#6b7280', l: '종료' }].map(s => (
|
||
<div key={s.l} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: s.c }} />
|
||
<span style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{s.l}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginTop: 2 }}>AIS 선박</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 12px' }}>
|
||
{VESSEL_LEGEND.map(vl => (
|
||
<div key={vl.type} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<div style={{ width: 0, height: 0, borderLeft: '3px solid transparent', borderRight: '3px solid transparent', borderBottom: `7px solid ${vl.color}` }} />
|
||
<span style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{vl.type}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vessel Popup */}
|
||
{selectedVessel && !detailVessel && (
|
||
<VesselPopupPanel
|
||
vessel={selectedVessel}
|
||
onClose={() => setSelectedVessel(null)}
|
||
onDetail={() => { setDetailVessel(selectedVessel); setSelectedVessel(null) }}
|
||
/>
|
||
)}
|
||
{detailVessel && (
|
||
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 2분할 View ─────────────────────────────── */}
|
||
{analysisActive && viewMode === 'split2' && (
|
||
<div style={{ display: 'flex', height: '100%' }}>
|
||
<div style={{ flex: 1, borderRight: '2px solid var(--cyan)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: 28, background: 'var(--bg1)', borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 10px', flexShrink: 0,
|
||
}}>
|
||
<span style={{ fontSize: 9, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--cyan)' }}>
|
||
{analysisTags[0] ? `${analysisTags[0].icon} ${analysisTags[0].label}` : '— 분석 결과를 선택하세요 —'}
|
||
</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 12, background: 'var(--bg0)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<SplitPanelContent tag={analysisTags[0]} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: 28, background: 'var(--bg1)', borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 10px', flexShrink: 0,
|
||
}}>
|
||
<span style={{ fontSize: 9, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--cyan)' }}>
|
||
{analysisTags[1] ? `${analysisTags[1].icon} ${analysisTags[1].label}` : '— 분석 결과를 선택하세요 —'}
|
||
</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 12, background: 'var(--bg0)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<SplitPanelContent tag={analysisTags[1]} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── 3분할 View ─────────────────────────────── */}
|
||
{analysisActive && viewMode === 'split3' && (
|
||
<div style={{ display: 'flex', height: '100%' }}>
|
||
{/* Oil spill */}
|
||
<div style={{ flex: 1, borderRight: '1px solid var(--bd)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: 28, flexShrink: 0,
|
||
background: 'linear-gradient(90deg,rgba(249,115,22,0.08),var(--bg1))',
|
||
borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 10px',
|
||
}}>
|
||
<span style={{ fontSize: 9, fontWeight: 700, fontFamily: 'var(--fK)', color: '#f97316' }}>🛢 유출유 확산예측</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 10, background: 'var(--bg0)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<SplitPanelContent tag={{ icon: '🛢', label: '유출유', color: '#f97316' }} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
{/* HNS */}
|
||
<div style={{ flex: 1, borderRight: '1px solid var(--bd)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: 28, flexShrink: 0,
|
||
background: 'linear-gradient(90deg,rgba(168,85,247,0.08),var(--bg1))',
|
||
borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 10px',
|
||
}}>
|
||
<span style={{ fontSize: 9, fontWeight: 700, fontFamily: 'var(--fK)', color: '#a855f7' }}>🧪 HNS 대기확산</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 10, background: 'var(--bg0)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<SplitPanelContent tag={{ icon: '🧪', label: 'HNS', color: '#a855f7' }} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
{/* Emergency rescue */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
height: 28, flexShrink: 0,
|
||
background: 'linear-gradient(90deg,rgba(6,182,212,0.08),var(--bg1))',
|
||
borderBottom: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', padding: '0 10px',
|
||
}}>
|
||
<span style={{ fontSize: 9, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--cyan)' }}>🚨 긴급구난</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 10, background: 'var(--bg0)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<SplitPanelContent tag={{ icon: '🚨', label: '구난', color: '#06b6d4' }} incident={selectedIncident} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Decision Bar (shown at bottom when analysis is active) */}
|
||
{analysisActive && (
|
||
<div style={{
|
||
flexShrink: 0, padding: '6px 16px',
|
||
background: 'var(--bg1)', borderTop: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
}}>
|
||
<div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')} 분석 결과 비교
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<button style={{
|
||
padding: '4px 12px', borderRadius: 4, fontSize: 9, fontWeight: 600,
|
||
fontFamily: 'var(--fK)', cursor: 'pointer',
|
||
background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)', color: '#58a6ff',
|
||
}}>📋 보고서 생성</button>
|
||
<button style={{
|
||
padding: '4px 12px', borderRadius: 4, fontSize: 9, fontWeight: 600,
|
||
fontFamily: 'var(--fK)', cursor: 'pointer',
|
||
background: 'rgba(168,85,247,0.1)', border: '1px solid rgba(168,85,247,0.2)', color: '#a78bfa',
|
||
}}>🔗 R&D 연계</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Right Panel */}
|
||
<IncidentsRightPanel
|
||
incident={selectedIncident}
|
||
viewMode={viewMode}
|
||
onViewModeChange={setViewMode}
|
||
onRunAnalysis={handleRunAnalysis}
|
||
analysisActive={analysisActive}
|
||
onCloseAnalysis={handleCloseAnalysis}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
SplitPanelContent – 분할뷰 패널 내용
|
||
════════════════════════════════════════════════════ */
|
||
function SplitPanelContent({ tag, incident }: {
|
||
tag?: { icon: string; label: string; color: string }
|
||
incident: Incident | null
|
||
}) {
|
||
if (!tag) {
|
||
return (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--t3)', fontSize: 11, fontFamily: 'var(--fK)' }}>
|
||
R&D 분석 결과를 선택하세요
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const mockData: Record<string, {
|
||
title: string; model: string; items: { label: string; value: string; color?: string }[]
|
||
summary: string
|
||
}> = {
|
||
'유출유': {
|
||
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 */}
|
||
<div style={{
|
||
padding: '10px 12px', borderRadius: 6,
|
||
background: `${tag.color}08`, border: `1px solid ${tag.color}20`,
|
||
}}>
|
||
<div style={{ fontSize: 11, fontWeight: 700, color: tag.color, fontFamily: 'var(--fK)', marginBottom: 4 }}>
|
||
{tag.icon} {data.title}
|
||
</div>
|
||
<div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{data.model}</div>
|
||
{incident && (
|
||
<div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: 2 }}>
|
||
사고: {incident.name} · {incident.date} {incident.time}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Data items */}
|
||
<div style={{
|
||
borderRadius: 6, border: '1px solid var(--bd)', overflow: 'hidden',
|
||
}}>
|
||
{data.items.map((item, i) => (
|
||
<div key={i} style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '6px 10px', borderBottom: i < data.items.length - 1 ? '1px solid var(--bd)' : 'none',
|
||
background: i % 2 === 0 ? 'var(--bg1)' : 'var(--bg2)',
|
||
}}>
|
||
<span style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{item.label}</span>
|
||
<span style={{ fontSize: 10, fontWeight: 600, color: item.color || 'var(--t1)', fontFamily: 'var(--fM)' }}>{item.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Summary */}
|
||
<div style={{
|
||
padding: '8px 10px', borderRadius: 6,
|
||
background: `${tag.color}06`, border: `1px solid ${tag.color}15`,
|
||
fontSize: 9, color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.6,
|
||
}}>
|
||
💡 {data.summary}
|
||
</div>
|
||
|
||
{/* Mock visualization */}
|
||
<div style={{
|
||
height: 120, borderRadius: 6, background: 'var(--bg0)',
|
||
border: '1px solid var(--bd)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 4,
|
||
}}>
|
||
<div style={{ fontSize: 32, opacity: 0.3 }}>{tag.icon}</div>
|
||
<div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>시각화 영역</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
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 (
|
||
<div style={{
|
||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)',
|
||
zIndex: 9995, width: 300,
|
||
background: '#161b22', border: '1px solid #30363d', borderRadius: 12,
|
||
boxShadow: '0 16px 48px rgba(0,0,0,0.6)', overflow: 'hidden', fontFamily: 'var(--fK)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
padding: '10px 14px', background: 'linear-gradient(135deg,#1c2333,#161b22)',
|
||
borderBottom: '1px solid #30363d',
|
||
}}>
|
||
<div style={{ width: 28, height: 20, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
|
||
{v.flag}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 800, color: '#f0f6fc', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{v.name}
|
||
</div>
|
||
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>MMSI: {v.mmsi}</div>
|
||
</div>
|
||
<span onClick={onClose} style={{ fontSize: 14, cursor: 'pointer', color: '#8b949e', padding: 2 }}>✕</span>
|
||
</div>
|
||
|
||
{/* Ship Image */}
|
||
<div style={{
|
||
width: '100%', height: 120, background: '#0d1117',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
borderBottom: '1px solid #21262d', fontSize: 40, color: '#30363d',
|
||
}}>
|
||
🚢
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div style={{ padding: '6px 14px', display: 'flex', gap: 8, borderBottom: '1px solid #21262d' }}>
|
||
<span style={{
|
||
padding: '2px 8px', background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.25)',
|
||
borderRadius: 4, fontSize: 8, fontWeight: 700, color: '#58a6ff', fontFamily: 'var(--fK)',
|
||
}}>{v.typS}</span>
|
||
<span style={{
|
||
padding: '2px 8px', background: statusBg, border: `1px solid ${statusColor}40`,
|
||
borderRadius: 4, fontSize: 8, fontWeight: 700, color: statusColor, fontFamily: 'var(--fK)',
|
||
}}>{v.status}</span>
|
||
</div>
|
||
|
||
{/* Data rows */}
|
||
<div style={{ padding: '4px 0' }}>
|
||
<PopupRow label="속도/항로" value={`${v.speed} kn / ${v.heading}°`} accent />
|
||
<PopupRow label="흘수" value={`${v.draft}m`} />
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '6px 14px', borderBottom: '1px solid rgba(48,54,61,0.4)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 10, color: '#8b949e' }}>출항지</span>
|
||
<span style={{ fontSize: 10, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fM)' }}>{v.depart}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 10, color: '#8b949e' }}>입항지</span>
|
||
<span style={{ fontSize: 10, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fM)' }}>{v.arrive}</span>
|
||
</div>
|
||
</div>
|
||
<PopupRow label="데이터 수신" value="2026-02-25 14:32:00" muted />
|
||
</div>
|
||
|
||
{/* Buttons */}
|
||
<div style={{ display: 'flex', gap: 6, padding: '10px 14px' }}>
|
||
<button onClick={onDetail} style={{
|
||
flex: 1, padding: 6, borderRadius: 6, fontSize: 10, fontWeight: 700, cursor: 'pointer',
|
||
textAlign: 'center', fontFamily: 'var(--fK)',
|
||
background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.3)', color: '#58a6ff',
|
||
}}>📋 상세정보</button>
|
||
<button style={{
|
||
flex: 1, padding: 6, borderRadius: 6, fontSize: 10, fontWeight: 700, cursor: 'pointer',
|
||
textAlign: 'center', fontFamily: 'var(--fK)',
|
||
background: 'rgba(168,85,247,0.1)', border: '1px solid rgba(168,85,247,0.25)', color: '#a78bfa',
|
||
}}>🔍 항적조회</button>
|
||
<button style={{
|
||
flex: 1, padding: 6, borderRadius: 6, fontSize: 10, fontWeight: 700, cursor: 'pointer',
|
||
textAlign: 'center', fontFamily: 'var(--fK)',
|
||
background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.25)', color: '#22d3ee',
|
||
}}>📐 항로예측</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PopupRow({ label, value, accent, muted }: { label: string; value: string; accent?: boolean; muted?: boolean }) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 14px', fontSize: 10, borderBottom: '1px solid rgba(48,54,61,0.4)' }}>
|
||
<span style={{ color: '#8b949e' }}>{label}</span>
|
||
<span style={{ color: muted ? '#8b949e' : accent ? '#22d3ee' : '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fM)', fontSize: muted ? 9 : 10 }}>{value}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
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<DetTab>('info')
|
||
|
||
return (
|
||
<div onClick={(e) => { 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)',
|
||
}}>
|
||
<div style={{
|
||
width: 560, height: '85vh', background: '#161b22',
|
||
border: '1px solid #30363d', borderRadius: 14, overflow: 'hidden',
|
||
display: 'flex', flexDirection: 'column',
|
||
boxShadow: '0 24px 64px rgba(0,0,0,0.7)',
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
flexShrink: 0,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '14px 18px', background: 'linear-gradient(135deg,#1c2333,#161b22)',
|
||
borderBottom: '1px solid #30363d',
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<span style={{ fontSize: 18 }}>{v.flag}</span>
|
||
<div>
|
||
<div style={{ fontSize: 14, fontWeight: 800, color: '#f0f6fc' }}>{v.name}</div>
|
||
<div style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>
|
||
MMSI: {v.mmsi} · IMO: {v.imo}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<span onClick={onClose} style={{ fontSize: 16, cursor: 'pointer', color: '#8b949e' }}>✕</span>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div style={{
|
||
flexShrink: 0,
|
||
display: 'flex', gap: 2, padding: '0 18px', background: '#0d1117',
|
||
borderBottom: '1px solid #21262d', overflowX: 'auto',
|
||
}}>
|
||
{TAB_LABELS.map(t => (
|
||
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||
padding: '8px 11px', fontSize: 11, fontWeight: tab === t.key ? 600 : 400,
|
||
color: tab === t.key ? '#58a6ff' : '#8b949e', cursor: 'pointer',
|
||
borderBottom: tab === t.key ? '2px solid #58a6ff' : '2px solid transparent',
|
||
fontFamily: 'var(--fK)', background: 'none', border: 'none',
|
||
whiteSpace: 'nowrap', transition: '0.15s',
|
||
}}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Body – scrollable */}
|
||
<div style={{
|
||
flex: 1, overflowY: 'auto', padding: '16px 18px',
|
||
display: 'flex', flexDirection: 'column', gap: 14,
|
||
scrollbarWidth: 'thin', scrollbarColor: '#30363d transparent',
|
||
}}>
|
||
{tab === 'info' && <TabInfo v={v} />}
|
||
{tab === 'nav' && <TabNav v={v} />}
|
||
{tab === 'spec' && <TabSpec v={v} />}
|
||
{tab === 'ins' && <TabInsurance v={v} />}
|
||
{tab === 'dg' && <TabDangerous v={v} />}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── shared section helpers ──────────────────────── */
|
||
function Sec({ title, borderColor, bgColor, badge, children }: {
|
||
title: string; borderColor?: string; bgColor?: string; badge?: React.ReactNode; children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div style={{ border: `1px solid ${borderColor || '#21262d'}`, borderRadius: 8, overflow: 'hidden' }}>
|
||
<div style={{
|
||
padding: '8px 12px', background: bgColor || '#0d1117', fontSize: 11,
|
||
fontWeight: 700, color: '#c9d1d9', borderBottom: `1px solid ${borderColor || '#21262d'}`,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
}}>
|
||
<span>{title}</span>{badge}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Grid({ children }: { children: React.ReactNode }) {
|
||
return <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>{children}</div>
|
||
}
|
||
|
||
function Cell({ label, value, span, color }: { label: string; value: string; span?: boolean; color?: string }) {
|
||
return (
|
||
<div style={{
|
||
padding: '8px 12px', borderBottom: '1px solid rgba(33,38,45,0.6)',
|
||
borderRight: span ? 'none' : '1px solid rgba(33,38,45,0.6)',
|
||
gridColumn: span ? '1 / -1' : undefined,
|
||
}}>
|
||
<div style={{ fontSize: 9, color: '#8b949e', marginBottom: 2 }}>{label}</div>
|
||
<div style={{ fontSize: 11, color: color || '#f0f6fc', fontWeight: 600, fontFamily: 'var(--fM)' }}>{value}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatusBadge({ label, color }: { label: string; color: string }) {
|
||
return (
|
||
<span style={{
|
||
fontSize: 8, padding: '2px 6px', borderRadius: 8, fontWeight: 700, marginLeft: 'auto',
|
||
background: `${color}25`, color,
|
||
}}>{label}</span>
|
||
)
|
||
}
|
||
|
||
/* ── Tab 0: 상세정보 ─────────────────────────────── */
|
||
function TabInfo({ v }: { v: Vessel }) {
|
||
return (
|
||
<>
|
||
{/* Ship image */}
|
||
<div style={{
|
||
width: '100%', height: 160, background: '#0d1117', borderRadius: 8, overflow: 'hidden',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 60, color: '#30363d',
|
||
}}>🚢</div>
|
||
|
||
<Sec title="📡 실시간 현황">
|
||
<Grid>
|
||
<Cell label="선박상태" value={v.status} />
|
||
<Cell label="속도 / 항로" value={`${v.speed} kn / ${v.heading}°`} color="#22d3ee" />
|
||
<Cell label="위도" value={`${v.lat.toFixed(4)}°N`} />
|
||
<Cell label="경도" value={`${v.lng.toFixed(4)}°E`} />
|
||
<Cell label="흘수" value={`${v.draft}m`} />
|
||
<Cell label="수신시간" value="2026-02-25 14:30" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="🚢 항해 일정">
|
||
<Grid>
|
||
<Cell label="출항지" value={v.depart} />
|
||
<Cell label="입항지" value={v.arrive} />
|
||
<Cell label="출항일시" value={v.etd || '—'} />
|
||
<Cell label="입항일시(ETA)" value={v.eta || '—'} />
|
||
</Grid>
|
||
</Sec>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ── 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 (
|
||
<>
|
||
<Sec title="🗺 최근 항적 (24h)">
|
||
<div style={{ height: 180, background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}>
|
||
<svg width="100%" height="100%" viewBox="0 0 400 180" style={{ position: 'absolute', inset: 0 }}>
|
||
<path d="M50,150 C80,140 120,100 160,95 S240,70 280,50 S340,30 370,20" fill="none" stroke="#58a6ff" strokeWidth="2" strokeDasharray="6,3" opacity=".6" />
|
||
<circle cx="50" cy="150" r="4" fill="#8b949e" />
|
||
<circle cx="160" cy="95" r="3" fill="#58a6ff" opacity=".5" />
|
||
<circle cx="280" cy="50" r="3" fill="#58a6ff" opacity=".5" />
|
||
<circle cx="370" cy="20" r="5" fill="#58a6ff" />
|
||
<text x="45" y="168" fill="#8b949e" fontSize="9" fontFamily="monospace">08:00</text>
|
||
<text x="150" y="113" fill="#8b949e" fontSize="9" fontFamily="monospace">10:30</text>
|
||
<text x="270" y="68" fill="#8b949e" fontSize="9" fontFamily="monospace">12:45</text>
|
||
<text x="350" y="16" fill="#58a6ff" fontSize="9" fontFamily="monospace">현재</text>
|
||
</svg>
|
||
</div>
|
||
</Sec>
|
||
|
||
<Sec title="📊 속도 이력">
|
||
<div style={{ padding: 12, background: '#0d1117' }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 80 }}>
|
||
{hours.map((h, i) => (
|
||
<div key={h} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||
<div style={{ width: '100%', background: colors[i], borderRadius: '2px 2px 0 0', height: `${heights[i]}%` }} />
|
||
<span style={{ fontSize: 7, color: '#8b949e' }}>{h}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ textAlign: 'center', marginTop: 6, fontSize: 8, color: '#8b949e' }}>
|
||
평균: <b style={{ color: '#58a6ff' }}>8.4 kn</b> · 최대: <b style={{ color: '#22d3ee' }}>11.2 kn</b>
|
||
</div>
|
||
</div>
|
||
</Sec>
|
||
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<ActionBtn icon="🔍" label="전체 항적 조회" bg="rgba(168,85,247,0.1)" bd="rgba(168,85,247,0.25)" fg="#a78bfa" />
|
||
<ActionBtn icon="📐" label="항로 예측" bg="rgba(6,182,212,0.1)" bd="rgba(6,182,212,0.25)" fg="#22d3ee" />
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ── Tab 2: 선박제원 ─────────────────────────────── */
|
||
function TabSpec({ v }: { v: Vessel }) {
|
||
return (
|
||
<>
|
||
<Sec title="📐 선체 제원">
|
||
<Grid>
|
||
<Cell label="선종" value={v.typS} />
|
||
<Cell label="선적국" value={`${v.flag}`} />
|
||
<Cell label="총톤수 (GT)" value={v.gt} />
|
||
<Cell label="재화중량 (DWT)" value={v.dwt} />
|
||
<Cell label="전장 (LOA)" value={v.loa} />
|
||
<Cell label="선폭" value={v.beam} />
|
||
<Cell label="건조년도" value={v.built} />
|
||
<Cell label="건조 조선소" value={v.yard} />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="📡 통신 / 식별">
|
||
<Grid>
|
||
<Cell label="MMSI" value={String(v.mmsi)} />
|
||
<Cell label="IMO" value={v.imo} />
|
||
<Cell label="호출부호" value={v.callSign} />
|
||
<Cell label="선급" value={v.cls} />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="⚠ 위험물 적재 정보">
|
||
<div style={{ padding: '10px 12px', background: '#0d1117' }}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px',
|
||
background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.12)', borderRadius: 4,
|
||
}}>
|
||
<span style={{ fontSize: 12 }}>🛢</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 10, fontWeight: 600, color: '#f0f6fc' }}>{v.cargo.split('·')[0].trim()}</div>
|
||
<div style={{ fontSize: 8, color: '#8b949e' }}>{v.cargo}</div>
|
||
</div>
|
||
{v.cargo.includes('IMO') && (
|
||
<span style={{ fontSize: 8, padding: '2px 6px', background: 'rgba(239,68,68,0.15)', borderRadius: 3, color: '#f87171', fontWeight: 700 }}>위험</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Sec>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ── Tab 3: 보험정보 ─────────────────────────────── */
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function TabInsurance(_props: { v: Vessel }) {
|
||
return (
|
||
<>
|
||
<Sec title="🏢 선주 / 운항사">
|
||
<Grid>
|
||
<Cell label="선주" value="대한해운(주)" />
|
||
<Cell label="운항사" value="대한해운(주)" />
|
||
<Cell label="P&I Club" value="한국선주상호보험" span />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="🚢 선체보험 (H&M)" borderColor="rgba(6,182,212,0.2)" bgColor="rgba(6,182,212,0.06)"
|
||
badge={<StatusBadge label="유효" color="#22c55e" />}>
|
||
<Grid>
|
||
<Cell label="보험사" value="삼성화재해상보험" />
|
||
<Cell label="보험가액" value="USD 28,500,000" color="#22d3ee" />
|
||
<Cell label="보험기간" value="2025.01 ~ 2026.01" color="#22c55e" />
|
||
<Cell label="면책금" value="USD 150,000" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="📦 화물보험 (Cargo)" borderColor="rgba(168,85,247,0.2)" bgColor="rgba(168,85,247,0.06)"
|
||
badge={<StatusBadge label="유효" color="#22c55e" />}>
|
||
<Grid>
|
||
<Cell label="보험사" value="DB손해보험" />
|
||
<Cell label="보험가액" value="USD 42,100,000" color="#a855f7" />
|
||
<Cell label="적하물" value="벙커C유 72,850톤" />
|
||
<Cell label="조건" value="ICC(A) All Risks" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="🛢 유류오염배상 (CLC/IOPC)" borderColor="rgba(239,68,68,0.2)" bgColor="rgba(239,68,68,0.06)"
|
||
badge={<StatusBadge label="유효" color="#22c55e" />}>
|
||
<Grid>
|
||
<Cell label="배상보증서" value="유효 (2025-12-31)" color="#22c55e" />
|
||
<Cell label="발급기관" value="한국선주상호보험" />
|
||
<Cell label="CLC 한도" value="89.77M SDR" color="#ef4444" />
|
||
<Cell label="IOPC 기금" value="203M SDR" />
|
||
<Cell label="추가기금" value="750M SDR" />
|
||
<Cell label="총 배상한도" value="약 1,042.77M SDR" color="#f97316" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<div style={{
|
||
padding: '8px 10px', background: 'rgba(59,130,246,0.04)',
|
||
border: '1px solid rgba(59,130,246,0.1)', borderRadius: 6,
|
||
fontSize: 9, color: '#8b949e', lineHeight: 1.6, fontFamily: 'var(--fK)',
|
||
}}>
|
||
💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. 실시간 갱신 주기: 24시간
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/* ── Tab 4: 위험물정보 ───────────────────────────── */
|
||
function TabDangerous({ v }: { v: Vessel }) {
|
||
return (
|
||
<>
|
||
<Sec title="⚠ 위험물 화물 신고정보" bgColor="rgba(249,115,22,0.06)"
|
||
badge={<span style={{ fontSize: 8, padding: '2px 6px', background: 'rgba(239,68,68,0.15)', borderRadius: 8, color: '#ef4444', fontWeight: 700 }}>PORT-MIS</span>}>
|
||
<Grid>
|
||
<Cell label="화물명" value={v.cargo.split('·')[0].trim() || '—'} color="#f97316" />
|
||
<Cell label="컨테이너갯수/총량" value="— / 72,850 톤" />
|
||
<Cell label="하역업체코드" value="KRY-2847" />
|
||
<Cell label="하역기간" value="02-26 ~ 02-28" />
|
||
<Cell label="신고업체코드" value="DHW-0412" />
|
||
<Cell label="사용장소(부두)" value="여수 1부두 2선석" />
|
||
<Cell label="신고일시" value="2026-02-24 09:30" />
|
||
<Cell label="전출항지" value="울산항" />
|
||
<Cell label="EDI ID" value="EDI-2026022400187" />
|
||
<Cell label="수리일시" value="2026-02-24 10:15" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="📋 화물창 및 첨부">
|
||
<div style={{ padding: '12px', background: '#0d1117', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, fontFamily: 'var(--fK)', whiteSpace: 'nowrap' }}>
|
||
<span style={{ color: '#8b949e' }}>화물창 2개이상 여부</span>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<span style={{
|
||
width: 14, height: 14, borderRadius: '50%', border: '2px solid #22d3ee',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, color: '#22d3ee',
|
||
}}>✓</span>
|
||
<span style={{ fontWeight: 600, color: '#22d3ee', fontSize: 10 }}>예</span>
|
||
</span>
|
||
</div>
|
||
<button style={{
|
||
padding: '6px 14px', background: 'rgba(59,130,246,0.1)', border: '1px solid rgba(59,130,246,0.2)',
|
||
borderRadius: 4, fontSize: 10, fontWeight: 600, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)',
|
||
whiteSpace: 'nowrap', flexShrink: 0,
|
||
}}>📎 첨부[화물적부도]</button>
|
||
</div>
|
||
</Sec>
|
||
|
||
<Sec title="🔥 IMO 위험물 분류" borderColor="rgba(239,68,68,0.2)" bgColor="rgba(239,68,68,0.06)">
|
||
<Grid>
|
||
<Cell label="IMO Class" value="Class 3" color="#ef4444" />
|
||
<Cell label="분류" value="인화성 액체" />
|
||
<Cell label="UN No." value="UN 1993" />
|
||
<Cell label="포장등급" value="III" />
|
||
<Cell label="인화점" value="60°C 이상" color="#f97316" />
|
||
<Cell label="해양오염물질" value="해당 (P)" color="#ef4444" />
|
||
</Grid>
|
||
</Sec>
|
||
|
||
<Sec title="🚨 비상 대응 요약 (EmS)" bgColor="rgba(234,179,8,0.06)">
|
||
<div style={{ padding: '10px 12px', background: '#0d1117', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<EmsRow icon="🔥" label="화재시" value="포말소화제, CO₂ 소화기 사용 · 물분무 냉각" bg="rgba(239,68,68,0.05)" bd="rgba(239,68,68,0.12)" />
|
||
<EmsRow icon="🌊" label="유출시" value="오일펜스 전개 · 유흡착재 투입 · 해상 기름 회수" bg="rgba(59,130,246,0.05)" bd="rgba(59,130,246,0.12)" />
|
||
<EmsRow icon="🫁" label="보호장비" value="내화학장갑, 보안경, 방독마스크 · 레벨C 보호복" bg="rgba(168,85,247,0.05)" bd="rgba(168,85,247,0.12)" />
|
||
</div>
|
||
</Sec>
|
||
|
||
<div style={{
|
||
padding: '8px 10px', background: 'rgba(249,115,22,0.04)',
|
||
border: '1px solid rgba(249,115,22,0.1)', borderRadius: 6,
|
||
fontSize: 9, color: '#8b949e', lineHeight: 1.6, fontFamily: 'var(--fK)',
|
||
}}>
|
||
💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment 42-24) 기준.
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function EmsRow({ icon, label, value, bg, bd }: { icon: string; label: string; value: string; bg: string; bd: string }) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: bg, border: `1px solid ${bd}`, borderRadius: 4 }}>
|
||
<span style={{ fontSize: 13 }}>{icon}</span>
|
||
<div>
|
||
<div style={{ fontSize: 9, color: '#8b949e' }}>{label}</div>
|
||
<div style={{ fontSize: 10, fontWeight: 600, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>{value}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ActionBtn({ icon, label, bg, bd, fg }: { icon: string; label: string; bg: string; bd: string; fg: string }) {
|
||
return (
|
||
<button style={{
|
||
flex: 1, padding: 6, borderRadius: 6, fontSize: 10, fontWeight: 700, cursor: 'pointer',
|
||
textAlign: 'center', fontFamily: 'var(--fK)', background: bg, border: `1px solid ${bd}`, color: fg,
|
||
}}>{icon} {label}</button>
|
||
)
|
||
}
|