wing-ops/frontend/src/tabs/incidents/components/IncidentsView.tsx
htlee 628c07f4fb refactor(css): 인라인 style → Tailwind className 일괄 변환 (229건)
안전한 패턴 매칭으로 단독 color/background/fontWeight/fontSize/flex 스타일을
Tailwind 유틸리티 클래스로 변환. 혼합 style에서 개별 속성 추출은 제외하여
시각적 회귀 방지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:38:59 +09:00

1960 lines
66 KiB
TypeScript
Executable File

import { useState, useEffect, useMemo } from 'react'
import { Map, Popup, useControl } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, IconLayer } from '@deck.gl/layers'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
import { fetchIncidents } from '../services/incidentsApi'
import type { IncidentCompat } from '../services/incidentsApi'
// ── CartoDB Dark Matter 베이스맵 ────────────────────────
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
}
// ── DeckGLOverlay ──────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// ── 사고 상태 색상 ──────────────────────────────────────
function getMarkerColor(s: string): [number, number, number, number] {
if (s === 'active') return [239, 68, 68, 204]
if (s === 'investigating') return [245, 158, 11, 204]
return [107, 114, 128, 204]
}
function getMarkerStroke(s: string): [number, number, number, number] {
if (s === 'active') return [220, 38, 38, 255]
if (s === 'investigating') return [217, 119, 6, 255]
return [75, 85, 99, 255]
}
const getStatusLabel = (s: string) =>
s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : ''
// ── 선박 아이콘 SVG (삼각형) ────────────────────────────
// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신
// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어)
// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현
// 팝업 정보
interface VesselPopupInfo {
longitude: number
latitude: number
vessel: Vessel
}
interface IncidentPopupInfo {
longitude: number
latitude: number
incident: IncidentCompat
}
// 호버 툴팁 정보
interface HoverInfo {
x: number
y: number
object: Vessel | IncidentCompat
type: 'vessel' | 'incident'
}
/* ════════════════════════════════════════════════════
IncidentsView
════════════════════════════════════════════════════ */
export function IncidentsView() {
const [incidents, setIncidents] = useState<IncidentCompat[]>([])
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null)
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null)
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null)
const [vesselPopup, setVesselPopup] = useState<VesselPopupInfo | null>(null)
const [incidentPopup, setIncidentPopup] = useState<IncidentPopupInfo | null>(null)
const [hoverInfo, setHoverInfo] = useState<HoverInfo | 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 }[]>([])
useEffect(() => {
fetchIncidents().then(data => {
setIncidents(data)
if (data.length > 0) {
setSelectedIncidentId(data[0].id)
}
})
}, [])
const selectedIncident = incidents.find(i => i.id === selectedIncidentId) ?? null
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([])
}
// ── 사고 마커 (ScatterplotLayer) ──────────────────────
const incidentLayer = useMemo(
() =>
new ScatterplotLayer({
id: 'incidents',
data: incidents,
getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat],
getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12),
getFillColor: (d: IncidentCompat) => getMarkerColor(d.status),
getLineColor: (d: IncidentCompat) =>
selectedIncidentId === d.id ? [6, 182, 212, 255] : getMarkerStroke(d.status),
getLineWidth: (d: IncidentCompat) => (selectedIncidentId === d.id ? 3 : 2),
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 20,
radiusUnits: 'pixels',
pickable: true,
onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedIncidentId(info.object.id)
setIncidentPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
incident: info.object,
})
setVesselPopup(null)
}
},
onHover: (info: { object?: IncidentCompat; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'incident' })
} else {
setHoverInfo(h => (h?.type === 'incident' ? null : h))
}
},
updateTriggers: {
getRadius: [selectedIncidentId],
getLineColor: [selectedIncidentId],
getLineWidth: [selectedIncidentId],
},
}),
[incidents, selectedIncidentId],
)
// ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ──────
// 글로우 효과 + 드롭쉐도우가 있는 개선된 SVG 삼각형
const vesselIconLayer = useMemo(() => {
const makeTriangleSvg = (color: string, isAccident: boolean) => {
const opacity = isAccident ? '1' : '0.85'
const glowOpacity = isAccident ? '0.9' : '0.75'
const svgStr = [
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="20" viewBox="0 0 16 20">',
'<defs><filter id="g" x="-50%" y="-50%" width="200%" height="200%">',
'<feGaussianBlur stdDeviation="1.2"/></filter></defs>',
`<polygon points="8,0 15,20 1,20" fill="${color}" opacity="${glowOpacity}" filter="url(#g)"/>`,
`<polygon points="8,1 14,19 2,19" fill="${color}" opacity="${opacity}" stroke="${color}" stroke-width="0.5"/>`,
'</svg>',
].join('')
return `data:image/svg+xml;base64,${btoa(svgStr)}`
}
return new IconLayer({
id: 'vessel-icons',
data: mockVessels,
getPosition: (d: Vessel) => [d.lng, d.lat],
getIcon: (d: Vessel) => ({
url: makeTriangleSvg(d.color, d.status.includes('사고')),
width: 16,
height: 20,
anchorX: 8,
anchorY: 10,
}),
getSize: 16,
getAngle: (d: Vessel) => -d.heading,
sizeUnits: 'pixels',
sizeScale: 1,
pickable: true,
onClick: (info: { object?: Vessel; coordinate?: number[] }) => {
if (info.object && info.coordinate) {
setSelectedVessel(info.object)
setVesselPopup({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
vessel: info.object,
})
setIncidentPopup(null)
setDetailVessel(null)
}
},
onHover: (info: { object?: Vessel; x?: number; y?: number }) => {
if (info.object && info.x !== undefined && info.y !== undefined) {
setHoverInfo({ x: info.x, y: info.y, object: info.object, type: 'vessel' })
} else {
setHoverInfo(h => (h?.type === 'vessel' ? null : h))
}
},
})
}, [])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers: any[] = useMemo(
() => [incidentLayer, vesselIconLayer],
[incidentLayer, vesselIconLayer],
)
return (
<div className="flex flex-1 overflow-hidden">
{/* Left Panel */}
<IncidentsLeftPanel
incidents={incidents}
selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId}
/>
{/* Center - Map + Analysis Views */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Analysis Bar */}
{analysisActive && (
<div
className="shrink-0 flex items-center justify-between border-b border-border"
style={{
height: 36,
padding: '0 16px',
background: 'linear-gradient(90deg,rgba(6,182,212,0.06),var(--bg1))',
}}
>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold">
🔬
</span>
<span className="text-[9px] text-text-3">
{selectedIncident?.name}
</span>
<div className="flex gap-1">
{analysisTags.map((t, i) => (
<span
key={i}
className="text-[8px] font-semibold rounded-md"
style={{
padding: '2px 8px',
background: `${t.color}18`,
border: `1px solid ${t.color}40`,
color: t.color,
}}
>
{t.icon} {t.label}
</span>
))}
</div>
</div>
<div className="flex gap-1 ml-auto">
{(
[
{ mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' },
{ mode: 'split2' as ViewMode, icon: '◫', label: '2분할' },
{ mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' },
] as const
).map(v => (
<button
key={v.mode}
onClick={() => setViewMode(v.mode)}
className="text-[9px] font-semibold cursor-pointer rounded-sm"
style={{
padding: '3px 10px',
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}
className="text-[9px] font-semibold cursor-pointer rounded-sm"
style={{
padding: '3px 8px',
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 className="flex-1 relative overflow-hidden">
{/* Default Map (visible when not in analysis or in overlay mode) */}
{(!analysisActive || viewMode === 'overlay') && (
<div className="absolute inset-0">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.0, zoom: 7 }}
mapStyle={BASE_STYLE}
style={{ width: '100%', height: '100%', background: '#0a0e1a' }}
attributionControl={false}
>
<DeckGLOverlay layers={deckLayers} />
{/* 사고 팝업 */}
{incidentPopup && (
<Popup
longitude={incidentPopup.longitude}
latitude={incidentPopup.latitude}
anchor="bottom"
onClose={() => setIncidentPopup(null)}
closeButton={true}
closeOnClick={false}
>
<div className="text-center min-w-[180px] text-xs">
<div style={{ fontWeight: 600, marginBottom: 6, color: '#1a1a2e' }}>
{incidentPopup.incident.name}
</div>
<div style={{ fontSize: 11, color: '#555', lineHeight: 1.6 }}>
<div>: {getStatusLabel(incidentPopup.incident.status)}</div>
<div>
: {incidentPopup.incident.date} {incidentPopup.incident.time}
</div>
<div>: {incidentPopup.incident.office}</div>
{incidentPopup.incident.causeType && (
<div>: {incidentPopup.incident.causeType}</div>
)}
{incidentPopup.incident.prediction && (
<div style={{ color: '#0891b2' }}>{incidentPopup.incident.prediction}</div>
)}
</div>
</div>
</Popup>
)}
</Map>
{/* 호버 툴팁 */}
{hoverInfo && (
<div
className="absolute z-[1000] pointer-events-none rounded-md"
style={{
left: hoverInfo.x + 12,
top: hoverInfo.y - 12,
background: '#161b22',
border: '1px solid #30363d',
padding: '8px 12px',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
minWidth: 150,
}}
>
{hoverInfo.type === 'vessel' ? (
<VesselTooltipContent vessel={hoverInfo.object as Vessel} />
) : (
<IncidentTooltipContent incident={hoverInfo.object as IncidentCompat} />
)}
</div>
)}
{/* 분석 오버레이 (지도 위 시각효과) */}
{analysisActive && viewMode === 'overlay' && (
<div className="absolute inset-0 z-[500] pointer-events-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
className="absolute top-[10px] right-[10px] z-[500] rounded-md"
style={{
background: 'rgba(13,17,23,0.88)',
border: '1px solid #30363d',
padding: '8px 12px',
backdropFilter: 'blur(8px)',
}}
>
<div className="flex items-center gap-1.5 mb-[5px]">
<div
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: '#22c55e',
animation: 'pd 1.5s infinite',
}}
/>
<span className="text-[10px] font-bold">
AIS Live
</span>
<span className="text-[8px] text-text-3 font-mono">MarineTraffic</span>
</div>
<div className="flex gap-2.5 text-[9px] font-mono">
<div className="text-text-2">
<b className="text-primary-cyan">20</b>
</div>
<div className="text-text-2">
<b style={{ color: '#f87171' }}>6</b>
</div>
<div className="text-text-2">
<b style={{ color: '#06b6d4' }}>2</b>
</div>
</div>
</div>
{/* Legend */}
<div
className="absolute bottom-[10px] left-[10px] z-[500] rounded-md flex flex-col gap-1.5"
style={{
background: 'rgba(13,17,23,0.88)',
border: '1px solid #30363d',
padding: '8px 12px',
backdropFilter: 'blur(8px)',
}}
>
<div className="text-[9px] font-bold text-text-2">
</div>
<div className="flex gap-2.5">
{[
{ c: '#ef4444', l: '대응중' },
{ c: '#f59e0b', l: '조사중' },
{ c: '#6b7280', l: '종료' },
].map(s => (
<div key={s.l} className="flex items-center gap-1">
<div style={{ width: 8, height: 8, borderRadius: '50%', background: s.c }} />
<span className="text-[8px] text-text-3">{s.l}</span>
</div>
))}
</div>
<div className="text-[9px] font-bold text-text-2 mt-0.5">
AIS
</div>
<div className="flex flex-wrap gap-[6px_12px]">
{VESSEL_LEGEND.map(vl => (
<div key={vl.type} className="flex items-center gap-1">
<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)' }}>{vl.type}</span>
</div>
))}
</div>
</div>
{/* 선박 팝업 패널 */}
{vesselPopup && selectedVessel && !detailVessel && (
<VesselPopupPanel
vessel={selectedVessel}
onClose={() => {
setVesselPopup(null)
setSelectedVessel(null)
}}
onDetail={() => {
setDetailVessel(selectedVessel)
setVesselPopup(null)
setSelectedVessel(null)
}}
/>
)}
{detailVessel && (
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
)}
</div>
)}
{/* ── 2분할 View ─────────────────────────────── */}
{analysisActive && viewMode === 'split2' && (
<div className="flex h-full">
<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, 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 className="flex-1 flex flex-col 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, 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 className="flex h-full">
<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, 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>
<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, 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>
<div className="flex-1 flex flex-col 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, 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 */}
{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)' }}>
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')}
</div>
<div className="flex gap-[6px]">
<button
style={{
padding: '4px 12px',
borderRadius: 4,
fontSize: 9,
fontWeight: 600,
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,
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,
}}
>
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 (
<>
<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, 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)', marginTop: 2 }}>
: {incident.name} · {incident.date} {incident.time}
</div>
)}
</div>
<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)' }}>{item.label}</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
color: item.color || 'var(--t1)',
fontFamily: 'var(--fM)',
}}
>
{item.value}
</span>
</div>
))}
</div>
<div
style={{
padding: '8px 10px',
borderRadius: 6,
background: `${tag.color}06`,
border: `1px solid ${tag.color}15`,
fontSize: 9,
color: 'var(--t2)',
lineHeight: 1.6,
}}
>
💡 {data.summary}
</div>
<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)' }}> </div>
</div>
</>
)
}
/* ════════════════════════════════════════════════════
VesselPopupPanel
════════════════════════════════════════════════════ */
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',
}}
>
{/* 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',
}}
>
{v.typS}
</span>
<span
style={{
padding: '2px 8px',
background: statusBg,
border: `1px solid ${statusColor}40`,
borderRadius: 4,
fontSize: 8,
fontWeight: 700,
color: statusColor,
}}
>
{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',
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',
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',
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
════════════════════════════════════════════════════ */
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 className="flex items-center gap-[10px]">
<span className="text-lg">{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',
background: 'none',
border: 'none',
whiteSpace: 'nowrap',
transition: '0.15s',
}}
>
{t.label}
</button>
))}
</div>
{/* Body */}
<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 (
<>
<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 className="flex gap-[8px]">
<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,
}}
>
💡 (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,
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',
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,
}}
>
💡 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' }}>{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',
background: bg,
border: `1px solid ${bd}`,
color: fg,
}}
>
{icon} {label}
</button>
)
}
/* ════════════════════════════════════════════════════
호버 툴팁 컴포넌트
════════════════════════════════════════════════════ */
function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) {
return (
<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f0f6fc', marginBottom: 3 }}>{v.name}</div>
<div style={{ fontSize: 9, color: '#8b949e', marginBottom: 4 }}>
{v.typS} · {v.flag}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 9 }}>
<span style={{ color: '#22d3ee', fontWeight: 600 }}>{v.speed} kn</span>
<span style={{ color: '#8b949e' }}>HDG {v.heading}°</span>
</div>
</>
)
}
function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) {
const statusColor =
i.status === 'active' ? '#ef4444' : i.status === 'investigating' ? '#f59e0b' : '#6b7280'
return (
<>
<div style={{ fontSize: 11, fontWeight: 700, color: '#f0f6fc', marginBottom: 3 }}>{i.name}</div>
<div style={{ fontSize: 9, color: '#8b949e', marginBottom: 4 }}>
{i.date} {i.time}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{
fontSize: 9,
fontWeight: 600,
color: statusColor,
}}
>
{getStatusLabel(i.status)}
</span>
<span style={{ fontSize: 9, color: '#58a6ff', fontFamily: 'monospace' }}>
{i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
</span>
</div>
</>
)
}