wing-ops/frontend/src/components/views/IncidentsView.tsx
htlee a0f64e4b11 style: 기존 코드 ESLint/TypeScript 에러 수정
- 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>
2026-02-27 15:47:29 +09:00

1039 lines
52 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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='&copy; <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>
)
}