import { useState, useEffect, useMemo, useRef } from 'react' import { Map as MapLibre, Popup, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, IconLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers' import { PathStyleExtension } from '@deck.gl/extensions' import 'maplibre-gl/dist/maplibre-gl.css' import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' import { S57EncOverlay } from '@common/components/map/S57EncOverlay' 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' import { fetchAnalysisTrajectory } from '@tabs/prediction/services/predictionApi' import type { TrajectoryResponse, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import { DischargeZonePanel } from './DischargeZonePanel' import { estimateDistanceFromCoast, getDischargeZoneLines } from '../utils/dischargeZoneData' import { useMapStore } from '@common/store/mapStore' import { useMeasureTool } from '@common/hooks/useMeasureTool' import { buildMeasureLayers } from '@common/components/map/measureLayers' import { MeasureOverlay } from '@common/components/map/MeasureOverlay' // ── 민감자원 카테고리별 색상 (목록 순서 인덱스 기반 — 중복 없음) ──────────── const CATEGORY_PALETTE: [number, number, number][] = [ [239, 68, 68 ], // red [249, 115, 22 ], // orange [234, 179, 8 ], // yellow [132, 204, 22 ], // lime [20, 184, 166], // teal [6, 182, 212], // cyan [59, 130, 246], // blue [99, 102, 241], // indigo [168, 85, 247], // purple [236, 72, 153], // pink [244, 63, 94 ], // rose [16, 185, 129], // emerald [14, 165, 233], // sky [139, 92, 246], // violet [217, 119, 6 ], // amber [45, 212, 191], // turquoise ] function getCategoryColor(index: number): [number, number, number] { return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length] } // ── DeckGLOverlay ────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) overlay.setProps({ layers }) return null } // ── FlyToController: 사고 선택 시 지도 이동 ────────── function FlyToController({ incident }: { incident: IncidentCompat | null }) { const { current: map } = useMap() const prevIdRef = useRef(null) useEffect(() => { if (!map || !incident) return if (prevIdRef.current === incident.id) return prevIdRef.current = incident.id map.flyTo({ center: [incident.location.lon, incident.location.lat], zoom: 10, duration: 800, }) }, [map, incident]) 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([]) const [selectedIncidentId, setSelectedIncidentId] = useState(null) const [selectedVessel, setSelectedVessel] = useState(null) const [detailVessel, setDetailVessel] = useState(null) const [vesselPopup, setVesselPopup] = useState(null) const [incidentPopup, setIncidentPopup] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) // Discharge zone mode const [dischargeMode, setDischargeMode] = useState(false) const [dischargeInfo, setDischargeInfo] = useState<{ lat: number; lon: number; distanceNm: number } | null>(null) // Map style & toggles const currentMapStyle = useBaseMapStyle(true) const mapToggles = useMapStore((s) => s.mapToggles) // Measure tool const { handleMeasureClick, measureMode } = useMeasureTool() const measureInProgress = useMapStore((s) => s.measureInProgress) const measurements = useMapStore((s) => s.measurements) // Analysis view mode const [viewMode, setViewMode] = useState('overlay') const [analysisActive, setAnalysisActive] = useState(false) const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([]) // 예측 trajectory & 민감자원 지도 표출 const [trajectoryEntries, setTrajectoryEntries] = useState>({}) const [sensitiveGeojson, setSensitiveGeojson] = useState(null) const [sensCheckedCategories, setSensCheckedCategories] = useState>(new Set()) const [sensColorMap, setSensColorMap] = useState>(new Map()) useEffect(() => { fetchIncidents().then(data => { setIncidents(data) }) }, []) // 사고 전환 시 지도 레이어 즉시 초기화 useEffect(() => { setTrajectoryEntries({}) setSensitiveGeojson(null) setSensCheckedCategories(new Set()) setSensColorMap(new Map()) }, [selectedIncidentId]) 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([]) } const handleCheckedPredsChange = async ( checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }> ) => { const newEntries: Record = {} await Promise.all( checked.map(async ({ id, acdntSn, predRunSn, occurredAt }) => { const existing = trajectoryEntries[id] if (existing) { newEntries[id] = existing; return } try { const data = await fetchAnalysisTrajectory(acdntSn, predRunSn ?? undefined) newEntries[id] = { data, occurredAt } } catch { /* 조용히 실패 */ } }) ) setTrajectoryEntries(newEntries) } const handleSensitiveDataChange = ( geojson: SensitiveResourceFeatureCollection | null, checkedCategories: Set, categoryOrder: string[] ) => { setSensitiveGeojson(geojson) setSensCheckedCategories(checkedCategories) const colorMap = new Map() categoryOrder.forEach((cat, i) => colorMap.set(cat, getCategoryColor(i))) setSensColorMap(colorMap) } // ── 사고 마커 (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) { const newId = selectedIncidentId === info.object.id ? null : info.object.id setSelectedIncidentId(newId) if (newId) { setIncidentPopup({ longitude: info.coordinate[0], latitude: info.coordinate[1], incident: info.object, }) } else { setIncidentPopup(null) } 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 = [ '', '', '', ``, ``, '', ].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)) } }, }) }, []) // ── 배출 구역 경계선 레이어 ── const dischargeZoneLayers = useMemo(() => { if (!dischargeMode) return [] const zoneLines = getDischargeZoneLines() return zoneLines.map((line, i) => new PathLayer({ id: `discharge-zone-${i}`, data: [line], getPath: (d: typeof line) => d.path, getColor: (d: typeof line) => d.color, getWidth: 2, widthUnits: 'pixels', getDashArray: [6, 3], dashJustified: true, extensions: [new PathStyleExtension({ dash: true })], pickable: false, }) ) }, [dischargeMode]) const measureDeckLayers = useMemo( () => buildMeasureLayers(measureInProgress, measureMode, measurements), [measureInProgress, measureMode, measurements], ) // ── 예측 결과 레이어 (입자 클라우드, 중심점 경로, 시간 라벨, 해안 부착 입자) ────── // eslint-disable-next-line @typescript-eslint/no-explicit-any const trajectoryLayers: any[] = useMemo(() => { const layers: unknown[] = [] // 모델별 색상 (prediction 탭과 동일) const MODEL_COLORS: Record = { 'KOSPS': [6, 182, 212], // cyan 'POSEIDON': [239, 68, 68], // red 'OpenDrift': [59, 130, 246], // blue 'default': [249, 115, 22], // orange } const pad = (n: number) => String(n).padStart(2, '0') let runIdx = 0 for (const [runId, entry] of Object.entries(trajectoryEntries)) { const { data: traj, occurredAt } = entry const { trajectory, centerPoints } = traj const startDt = new Date(occurredAt) runIdx++ if (trajectory && trajectory.length > 0) { const maxTime = Math.max(...trajectory.map(p => p.time)) // 최종 스텝 부유 입자: 모델별로 그룹핑하여 각각 다른 색 const lastStepByModel: Record = {} trajectory.forEach(p => { if (p.time === maxTime && p.stranded !== 1) { const m = p.model ?? 'default' if (!lastStepByModel[m]) lastStepByModel[m] = [] lastStepByModel[m].push(p) } }) Object.entries(lastStepByModel).forEach(([model, particles]) => { const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'] layers.push(new ScatterplotLayer({ id: `traj-particles-${runId}-${model}`, data: particles, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getFillColor: [...color, 180] as [number, number, number, number], getRadius: 3, radiusMinPixels: 2, radiusMaxPixels: 5, })) }) // 해안 부착 입자: 모델별 색상 + 테두리 강조 const beachedByModel: Record = {} trajectory.forEach(p => { if (p.stranded === 1) { const m = p.model ?? 'default' if (!beachedByModel[m]) beachedByModel[m] = [] beachedByModel[m].push(p) } }) Object.entries(beachedByModel).forEach(([model, particles]) => { const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'] layers.push(new ScatterplotLayer({ id: `traj-beached-${runId}-${model}`, data: particles, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getFillColor: [...color, 220] as [number, number, number, number], getRadius: 4, radiusMinPixels: 3, radiusMaxPixels: 6, stroked: true, getLineColor: [255, 255, 255, 160] as [number, number, number, number], getLineWidth: 1, lineWidthMinPixels: 1, })) }) } // 중심점 경로선 (모델별 그룹) if (centerPoints && centerPoints.length >= 2) { const byModel: Record = {} centerPoints.forEach(cp => { const m = cp.model ?? 'default' if (!byModel[m]) byModel[m] = [] byModel[m].push(cp) }) Object.entries(byModel).forEach(([model, pts]) => { const color = MODEL_COLORS[model] ?? MODEL_COLORS['default'] const sorted = [...pts].sort((a, b) => a.time - b.time) const pathId = `${runIdx}-${model}` layers.push(new PathLayer({ id: `traj-path-${pathId}`, data: [{ path: sorted.map(p => [p.lon, p.lat]) }], getPath: (d: { path: number[][] }) => d.path, getColor: [...color, 230] as [number, number, number, number], getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, })) layers.push(new ScatterplotLayer({ id: `traj-centers-${pathId}`, data: sorted, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getFillColor: [...color, 230] as [number, number, number, number], getRadius: 5, radiusMinPixels: 4, radiusMaxPixels: 8, })) layers.push(new TextLayer({ id: `traj-labels-${pathId}`, data: sorted, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getText: (d: { time: number }) => { const dt = new Date(startDt.getTime() + d.time * 3600 * 1000) return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}` }, getSize: 11, getColor: [...color, 240] as [number, number, number, number], getPixelOffset: [0, -14] as [number, number], outlineWidth: 2, outlineColor: [0, 0, 0, 180] as [number, number, number, number], fontSettings: { sdf: true }, billboard: true, })) }) } } return layers }, [trajectoryEntries]) // ── 민감자원 GeoJSON 레이어 ────────────────────────── const sensLayer = useMemo(() => { if (!sensitiveGeojson || sensCheckedCategories.size === 0) return null const filtered = { ...sensitiveGeojson, features: sensitiveGeojson.features.filter( f => sensCheckedCategories.has((f.properties as Record)?.['category'] as string ?? '') ), } if (filtered.features.length === 0) return null return new GeoJsonLayer({ id: 'incidents-sensitive-geojson', data: filtered, pickable: false, stroked: true, filled: true, pointRadiusMinPixels: 8, lineWidthMinPixels: 1, getFillColor: (f: { properties: Record }) => { const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [128, 128, 128] return [...color, 60] as [number, number, number, number] }, getLineColor: (f: { properties: Record }) => { const color = sensColorMap.get((f.properties['category'] as string) ?? '') ?? [128, 128, 128] return [...color, 180] as [number, number, number, number] }, getLineWidth: 1, updateTriggers: { getFillColor: [sensColorMap], getLineColor: [sensColorMap], }, }) }, [sensitiveGeojson, sensCheckedCategories, sensColorMap]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers: any[] = useMemo( () => [ incidentLayer, vesselIconLayer, ...dischargeZoneLayers, ...measureDeckLayers, ...trajectoryLayers, ...(sensLayer ? [sensLayer] : []), ], [incidentLayer, vesselIconLayer, dischargeZoneLayers, measureDeckLayers, trajectoryLayers, sensLayer], ) return (
{/* Left Panel */} {/* Center - Map + Analysis Views */}
{/* Analysis Bar */} {analysisActive && (
🔬 통합 분석 비교 {selectedIncident?.name}
{analysisTags.map((t, i) => ( {t.icon} {t.label} ))}
{( [ { mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' }, { mode: 'split2' as ViewMode, icon: '◫', label: '2분할' }, { mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' }, ] as const ).map(v => ( ))}
)} {/* Map / Analysis Content Area */}
{/* Default Map (visible when not in analysis or in overlay mode) */} {(!analysisActive || viewMode === 'overlay') && (
{ if (measureMode !== null && e.lngLat) { handleMeasureClick(e.lngLat.lng, e.lngLat.lat) return } if (dischargeMode && e.lngLat) { const lat = e.lngLat.lat const lon = e.lngLat.lng const distanceNm = estimateDistanceFromCoast(lat, lon) setDischargeInfo({ lat, lon, distanceNm }) } }} cursor={(measureMode !== null || dischargeMode) ? 'crosshair' : undefined} > {/* 사고 팝업 */} {incidentPopup && ( setIncidentPopup(null)} closeButton={true} closeOnClick={false} >
{incidentPopup.incident.name}
상태: {getStatusLabel(incidentPopup.incident.status)}
일시: {incidentPopup.incident.date} {incidentPopup.incident.time}
관할: {incidentPopup.incident.office}
{incidentPopup.incident.causeType && (
원인: {incidentPopup.incident.causeType}
)} {incidentPopup.incident.prediction && (
{incidentPopup.incident.prediction}
)}
)}
{/* 호버 툴팁 */} {hoverInfo && (
{hoverInfo.type === 'vessel' ? ( ) : ( )}
)} {/* 분석 오버레이 (지도 위 시각효과) */} {analysisActive && viewMode === 'overlay' && (
{analysisTags.some(t => t.label === '유출유') && (
)} {analysisTags.some(t => t.label === 'HNS') && (
)} {analysisTags.some(t => t.label === '구난') && (
)}
)} {/* 오염물 배출 규정 토글 */} {/* 오염물 배출 규정 패널 */} {dischargeMode && dischargeInfo && ( setDischargeInfo(null)} /> )} {/* 배출규정 모드 안내 */} {dischargeMode && !dischargeInfo && (
📍 지도를 클릭하여 배출 규정을 확인하세요
)} {/* AIS Live Badge */}
AIS Live MarineTraffic
선박 20
사고 6
방제선 2
{/* Legend */}
사고 상태
{[ { c: '#ef4444', l: '대응중' }, { c: '#f59e0b', l: '조사중' }, { c: '#6b7280', l: '종료' }, ].map(s => (
{s.l}
))}
AIS 선박
{VESSEL_LEGEND.map(vl => (
{vl.type}
))}
{/* 선박 팝업 패널 */} {vesselPopup && selectedVessel && !detailVessel && ( { setVesselPopup(null) setSelectedVessel(null) }} onDetail={() => { setDetailVessel(selectedVessel) setVesselPopup(null) setSelectedVessel(null) }} /> )} {detailVessel && ( setDetailVessel(null)} /> )}
)} {/* ── 2분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split2' && (
{analysisTags[0] ? `${analysisTags[0].icon} ${analysisTags[0].label}` : '— 분석 결과를 선택하세요 —'}
{analysisTags[1] ? `${analysisTags[1].icon} ${analysisTags[1].label}` : '— 분석 결과를 선택하세요 —'}
)} {/* ── 3분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split3' && (
🛢 유출유 확산예측
🧪 HNS 대기확산
🚨 긴급구난
)}
{/* Decision Bar */} {analysisActive && (
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')} 분석 결과 비교
)}
{/* Right Panel */}
) } /* ════════════════════════════════════════════════════ SplitPanelContent ════════════════════════════════════════════════════ */ function SplitPanelContent({ tag, incident, }: { tag?: { icon: string; label: string; color: string } incident: Incident | null }) { if (!tag) { return (
R&D 분석 결과를 선택하세요
) } const mockData: Record< 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 ( <>
{tag.icon} {data.title}
{data.model}
{incident && (
사고: {incident.name} · {incident.date} {incident.time}
)}
{data.items.map((item, i) => (
{item.label} {item.value}
))}
💡 {data.summary}
{tag.icon}
시각화 영역
) } /* ════════════════════════════════════════════════════ 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 (
{/* Header */}
{v.flag}
{v.name}
MMSI: {v.mmsi}
{/* Ship Image */}
🚢
{/* Tags */}
{v.typS} {v.status}
{/* Data rows */}
출항지 {v.depart}
입항지 {v.arrive}
{/* Buttons */}
) } function PopupRow({ label, value, accent, muted, }: { label: string value: string accent?: boolean muted?: boolean }) { return (
{label} {value}
) } /* ════════════════════════════════════════════════════ VesselDetailModal ════════════════════════════════════════════════════ */ type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg' const TAB_LABELS: { key: DetTab; label: string }[] = [ { key: 'info', label: '상세정보' }, { key: 'nav', label: '항해정보' }, { key: 'spec', label: '선박제원' }, { key: 'ins', label: '보험정보' }, { key: 'dg', label: '위험물정보' }, ] function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () => void }) { const [tab, setTab] = useState('info') return (
{ if (e.target === e.currentTarget) onClose() }} className="fixed inset-0 z-[10000] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(6px)', }} >
{/* Header */}
{v.flag}
{v.name}
MMSI: {v.mmsi} · IMO: {v.imo}
{/* Tabs */}
{TAB_LABELS.map(t => ( ))}
{/* Body */}
{tab === 'info' && } {tab === 'nav' && } {tab === 'spec' && } {tab === 'ins' && } {tab === 'dg' && }
) } /* ── shared section helpers ──────────────────────── */ function Sec({ title, borderColor, bgColor, badge, children, }: { title: string borderColor?: string bgColor?: string badge?: React.ReactNode children: React.ReactNode }) { return (
{title} {badge}
{children}
) } function Grid({ children }: { children: React.ReactNode }) { return
{children}
} function Cell({ label, value, span, color, }: { label: string value: string span?: boolean color?: string }) { return (
{label}
{value}
) } function StatusBadge({ label, color }: { label: string; color: string }) { return ( {label} ) } /* ── Tab 0: 상세정보 ─────────────────────────────── */ function TabInfo({ v }: { v: Vessel }) { return ( <>
🚢
) } /* ── 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 ( <>
08:00 10:30 12:45 현재
{hours.map((h, i) => (
{h}
))}
평균: 8.4 kn · 최대:{' '} 11.2 kn
) } /* ── Tab 2: 선박제원 ─────────────────────────────── */ function TabSpec({ v }: { v: Vessel }) { return ( <>
🛢
{v.cargo.split('·')[0].trim()}
{v.cargo}
{v.cargo.includes('IMO') && ( 위험 )}
) } /* ── Tab 3: 보험정보 ─────────────────────────────── */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function TabInsurance(_props: { v: Vessel }) { return ( <> } > } > } >
💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. 실시간 갱신 주기: 24시간
) } /* ── Tab 4: 위험물정보 ───────────────────────────── */ function TabDangerous({ v }: { v: Vessel }) { return ( <> PORT-MIS } >
화물창 2개이상 여부
💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment 42-24) 기준.
) } function EmsRow({ icon, label, value, bg, bd, }: { icon: string label: string value: string bg: string bd: string }) { return (
{icon}
{label}
{value}
) } function ActionBtn({ icon, label, bg, bd, fg, }: { icon: string label: string bg: string bd: string fg: string }) { return ( ) } /* ════════════════════════════════════════════════════ 호버 툴팁 컴포넌트 ════════════════════════════════════════════════════ */ function VesselTooltipContent({ vessel: v }: { vessel: Vessel }) { return ( <>
{v.name}
{v.typS} · {v.flag}
{v.speed} kn HDG {v.heading}°
) } function IncidentTooltipContent({ incident: i }: { incident: IncidentCompat }) { const statusColor = i.status === 'active' ? '#ef4444' : i.status === 'investigating' ? '#f59e0b' : '#6b7280' return ( <>
{i.name}
{i.date} {i.time}
{getStatusLabel(i.status)} {i.location.lat.toFixed(3)}°N, {i.location.lon.toFixed(3)}°E
) }