import { useRef, useEffect, useState } from 'react'; interface ReconItem { id: string name: string type: 'vessel' | 'pollution' status: 'complete' | 'processing' points: string polygons: string coverage: string } const reconItems: ReconItem[] = [ { id: 'V-001', name: '불명선박-A', type: 'vessel', status: 'complete', points: '980K', polygons: '38K', coverage: '97.1%' }, { id: 'V-002', name: '불명선박-B', type: 'vessel', status: 'complete', points: '1.2M', polygons: '48K', coverage: '98.4%' }, { id: 'V-003', name: '어선 #37', type: 'vessel', status: 'processing', points: '420K', polygons: '16K', coverage: '64.2%' }, { id: 'P-001', name: '유류오염-A', type: 'pollution', status: 'complete', points: '560K', polygons: '22K', coverage: '95.8%' }, { id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' }, ] function mulberry32(seed: number) { let s = seed; return () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) { const isProcessing = status === 'processing'; const isWire = viewMode === 'wire'; const isPoint = viewMode === 'point'; const canvasRef = useRef(null); const W = 420; const H = 200; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = `${W}px`; canvas.style.height = `${H}px`; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H); const cyanFull = 'rgba(6,182,212,'; const orangeFull = 'rgba(249,115,22,'; const redFull = 'rgba(239,68,68,'; const greenFull = 'rgba(34,197,94,'; const hullStroke = isProcessing ? `${cyanFull}0.2)` : `${cyanFull}0.5)`; const hullFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.08)`; const deckStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.45)`; const deckFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.05)`; const bridgeStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.5)`; const bridgeFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.1)`; const funnelStroke = isProcessing ? `${redFull}0.15)` : `${redFull}0.4)`; const funnelFill = isWire || isPoint ? 'transparent' : `${redFull}0.1)`; const craneStroke = isProcessing ? `${orangeFull}0.15)` : `${orangeFull}0.4)`; // 수선 (waterline) ctx.beginPath(); ctx.ellipse(210, 165, 200, 12, 0, 0, Math.PI * 2); ctx.strokeStyle = `${cyanFull}0.15)`; ctx.lineWidth = 0.5; ctx.setLineDash([4, 2]); ctx.stroke(); ctx.setLineDash([]); // 선체 (hull) ctx.beginPath(); ctx.moveTo(30, 140); ctx.quadraticCurveTo(40, 170, 100, 175); ctx.lineTo(320, 175); ctx.quadraticCurveTo(380, 170, 395, 140); ctx.lineTo(390, 100); ctx.quadraticCurveTo(385, 85, 370, 80); ctx.lineTo(50, 80); ctx.quadraticCurveTo(35, 85, 30, 100); ctx.closePath(); ctx.fillStyle = hullFill; ctx.fill(); ctx.strokeStyle = hullStroke; ctx.lineWidth = isWire ? 0.8 : 1.2; ctx.stroke(); // 선체 하부 ctx.beginPath(); ctx.moveTo(30, 140); ctx.quadraticCurveTo(20, 155, 60, 168); ctx.lineTo(100, 175); ctx.moveTo(395, 140); ctx.quadraticCurveTo(405, 155, 360, 168); ctx.lineTo(320, 175); ctx.strokeStyle = `${cyanFull}0.3)`; ctx.lineWidth = 0.7; ctx.stroke(); // 갑판 (deck) ctx.beginPath(); ctx.moveTo(50, 80); ctx.quadraticCurveTo(45, 65, 55, 60); ctx.lineTo(365, 60); ctx.quadraticCurveTo(375, 65, 370, 80); ctx.fillStyle = deckFill; ctx.fill(); ctx.strokeStyle = deckStroke; ctx.lineWidth = isWire ? 0.8 : 1; ctx.stroke(); // 선체 리브 (와이어프레임 / 포인트 모드) if (isWire || isPoint) { ctx.strokeStyle = `${cyanFull}0.15)`; ctx.lineWidth = 0.4; [80, 120, 160, 200, 240, 280, 320, 360].forEach(x => { ctx.beginPath(); ctx.moveTo(x, 60); ctx.lineTo(x, 175); ctx.stroke(); }); [80, 100, 120, 140, 160].forEach(y => { ctx.beginPath(); ctx.moveTo(30, y); ctx.lineTo(395, y); ctx.stroke(); }); } // 선교 (bridge) ctx.beginPath(); ctx.roundRect(260, 25, 70, 35, 2); ctx.fillStyle = bridgeFill; ctx.fill(); ctx.strokeStyle = bridgeStroke; ctx.lineWidth = isWire ? 0.8 : 1; ctx.stroke(); // 선교 창문 if (!isPoint) { ctx.strokeStyle = `${cyanFull}0.3)`; ctx.lineWidth = 0.5; [268, 282, 296, 310].forEach(wx => { ctx.beginPath(); ctx.roundRect(wx, 30, 10, 6, 1); ctx.stroke(); }); } // 마스트 ctx.strokeStyle = `${cyanFull}0.4)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(295, 25); ctx.lineTo(295, 8); ctx.stroke(); ctx.strokeStyle = `${cyanFull}0.3)`; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(288, 12); ctx.lineTo(302, 12); ctx.stroke(); // 연통 (funnel) ctx.beginPath(); ctx.roundRect(235, 38, 18, 22, 1); ctx.fillStyle = funnelFill; ctx.fill(); ctx.strokeStyle = funnelStroke; ctx.lineWidth = isWire ? 0.8 : 1; ctx.stroke(); // 화물 크레인 ctx.strokeStyle = craneStroke; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(150, 60); ctx.lineTo(150, 20); ctx.moveTo(150, 22); ctx.lineTo(120, 40); ctx.moveTo(180, 60); ctx.lineTo(180, 25); ctx.moveTo(180, 27); ctx.lineTo(155, 42); ctx.stroke(); // 포인트 클라우드 if (isPoint) { const rand = mulberry32(42); for (let i = 0; i < 5000; i++) { const x = 35 + rand() * 355; const y = 15 + rand() * 160; const inHull = y > 60 && y < 175 && x > 35 && x < 390; const inBridge = x > 260 && x < 330 && y > 25 && y < 60; if (!inHull && !inBridge && rand() > 0.15) continue; const alpha = 0.15 + rand() * 0.55; const r = 0.4 + rand() * 0.6; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = `${cyanFull}${alpha})`; ctx.fill(); } } // 선수/선미 표시 ctx.fillStyle = `${cyanFull}0.3)`; ctx.font = '8px var(--fM, monospace)'; ctx.fillText('선수', 395, 95); ctx.fillText('선미', 15, 95); // 측정선 (3D 모드) if (viewMode === '3d') { ctx.strokeStyle = `${greenFull}0.4)`; ctx.lineWidth = 0.5; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(30, 185); ctx.lineTo(395, 185); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = `${greenFull}0.6)`; ctx.font = '8px var(--fM, monospace)'; ctx.textAlign = 'center'; ctx.fillText('84.7m', 200, 195); ctx.textAlign = 'left'; ctx.strokeStyle = `${orangeFull}0.4)`; ctx.lineWidth = 0.5; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(405, 60); ctx.lineTo(405, 175); ctx.stroke(); ctx.setLineDash([]); ctx.save(); ctx.fillStyle = `${orangeFull}0.6)`; ctx.font = '8px var(--fM, monospace)'; ctx.translate(415, 120); ctx.rotate(Math.PI / 2); ctx.fillText('14.2m', 0, 0); ctx.restore(); } }, [viewMode, isProcessing, isWire, isPoint]); return (
{isProcessing && (
재구성 처리중...
)}
); } function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) { const isProcessing = status === 'processing'; const isWire = viewMode === 'wire'; const isPoint = viewMode === 'point'; const canvasRef = useRef(null); const W = 380; const H = 260; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = W * dpr; canvas.height = H * dpr; canvas.style.width = `${W}px`; canvas.style.height = `${H}px`; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, W, H); const cyanFull = 'rgba(6,182,212,'; const orangeFull = 'rgba(249,115,22,'; const redFull = 'rgba(239,68,68,'; const greenFull = 'rgba(34,197,94,'; const blueFull = 'rgba(59,130,246,'; const yellowFull = 'rgba(234,179,8,'; // 해수면 그리드 ctx.strokeStyle = `${cyanFull}0.08)`; ctx.lineWidth = 0.4; for (let i = 0; i < 15; i++) { ctx.beginPath(); ctx.moveTo(0, i * 20); ctx.lineTo(380, i * 20); ctx.stroke(); } for (let i = 0; i < 20; i++) { ctx.beginPath(); ctx.moveTo(i * 20, 0); ctx.lineTo(i * 20, 260); ctx.stroke(); } // 와이어프레임 / 포인트 모드 등고선 타원 if (isWire || isPoint) { ctx.strokeStyle = `${redFull}0.12)`; ctx.lineWidth = 0.3; ctx.setLineDash([]); [[140, 80], [100, 55], [60, 35]].forEach(([rx, ry]) => { ctx.beginPath(); ctx.ellipse(190, 145, rx, ry, 0, 0, Math.PI * 2); ctx.stroke(); }); } // 유막 메인 형태 (blob) ctx.beginPath(); ctx.moveTo(120, 80); ctx.quadraticCurveTo(80, 90, 70, 120); ctx.quadraticCurveTo(55, 155, 80, 180); ctx.quadraticCurveTo(100, 205, 140, 210); ctx.quadraticCurveTo(180, 220, 220, 205); ctx.quadraticCurveTo(270, 195, 300, 170); ctx.quadraticCurveTo(320, 145, 310, 115); ctx.quadraticCurveTo(300, 85, 270, 75); ctx.quadraticCurveTo(240, 65, 200, 70); ctx.quadraticCurveTo(160, 68, 120, 80); ctx.closePath(); ctx.fillStyle = isWire || isPoint ? 'transparent' : `${redFull}0.08)`; ctx.fill(); ctx.strokeStyle = isProcessing ? `${redFull}0.15)` : `${redFull}0.45)`; ctx.lineWidth = isWire ? 0.8 : 1.5; ctx.setLineDash([]); ctx.stroke(); // 유막 두께 등고선 ctx.beginPath(); ctx.moveTo(155, 100); ctx.quadraticCurveTo(125, 115, 120, 140); ctx.quadraticCurveTo(115, 165, 135, 180); ctx.quadraticCurveTo(155, 195, 190, 190); ctx.quadraticCurveTo(230, 185, 255, 165); ctx.quadraticCurveTo(270, 145, 260, 120); ctx.quadraticCurveTo(250, 100, 225, 95); ctx.quadraticCurveTo(195, 88, 155, 100); ctx.closePath(); ctx.fillStyle = isWire || isPoint ? 'transparent' : `${orangeFull}0.08)`; ctx.fill(); ctx.strokeStyle = isProcessing ? `${orangeFull}0.12)` : `${orangeFull}0.35)`; ctx.lineWidth = 0.8; if (isWire) ctx.setLineDash([4, 2]); else ctx.setLineDash([]); ctx.stroke(); ctx.setLineDash([]); // 유막 최고 두께 핵심 ctx.beginPath(); ctx.moveTo(175, 120); ctx.quadraticCurveTo(160, 130, 165, 150); ctx.quadraticCurveTo(170, 170, 195, 170); ctx.quadraticCurveTo(220, 168, 230, 150); ctx.quadraticCurveTo(235, 130, 220, 120); ctx.quadraticCurveTo(205, 110, 175, 120); ctx.closePath(); ctx.fillStyle = isWire || isPoint ? 'transparent' : `${redFull}0.15)`; ctx.fill(); ctx.strokeStyle = isProcessing ? `${redFull}0.15)` : `${redFull}0.5)`; ctx.lineWidth = 0.8; ctx.stroke(); // 확산 방향 화살표 ctx.strokeStyle = `${orangeFull}0.5)`; ctx.fillStyle = `${orangeFull}0.5)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(250, 140); ctx.lineTo(330, 120); ctx.stroke(); ctx.beginPath(); ctx.moveTo(330, 120); ctx.lineTo(322, 115); ctx.lineTo(324, 123); ctx.closePath(); ctx.fill(); ctx.fillStyle = `${orangeFull}0.6)`; ctx.font = '8px var(--fM, monospace)'; ctx.fillText('ESE 0.3km/h', 335, 122); // 포인트 클라우드 if (isPoint) { const rand = mulberry32(99); const cx = 190, cy = 145, rx = 130, ry = 75; for (let i = 0; i < 8000; i++) { const angle = rand() * Math.PI * 2; const r = Math.sqrt(rand()); const x = cx + r * rx * Math.cos(angle); const y = cy + r * ry * Math.sin(angle); if (x < 40 || x > 340 || y < 50 || y > 230) continue; const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2); const intensity = Math.max(0.1, 1 - dist); let color: string; if (dist < 0.4) color = `${redFull}${intensity * 0.7})`; else if (dist < 0.7) color = `${orangeFull}${intensity * 0.5})`; else color = `${yellowFull}${intensity * 0.3})`; const pr = 0.3 + rand() * 0.7; ctx.beginPath(); ctx.arc(x, y, pr, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); } } // 두께 색상 범례 텍스트 (3D 모드) if (viewMode === '3d') { ctx.textAlign = 'center'; ctx.font = '7px var(--fM, monospace)'; ctx.fillStyle = `${redFull}0.7)`; ctx.fillText('3.2mm', 165, 148); ctx.fillStyle = `${orangeFull}0.5)`; ctx.fillText('1.5mm', 130, 165); ctx.fillStyle = `${yellowFull}0.4)`; ctx.fillText('0.3mm', 95, 130); ctx.textAlign = 'left'; // 측정선 ctx.strokeStyle = `${greenFull}0.4)`; ctx.lineWidth = 0.5; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(55, 240); ctx.lineTo(320, 240); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = `${greenFull}0.6)`; ctx.font = '8px var(--fM, monospace)'; ctx.textAlign = 'center'; ctx.fillText('1.24 km', 187, 252); ctx.textAlign = 'left'; ctx.strokeStyle = `${blueFull}0.4)`; ctx.lineWidth = 0.5; ctx.setLineDash([3, 2]); ctx.beginPath(); ctx.moveTo(25, 80); ctx.lineTo(25, 210); ctx.stroke(); ctx.setLineDash([]); ctx.save(); ctx.fillStyle = `${blueFull}0.6)`; ctx.font = '8px var(--fM, monospace)'; ctx.translate(15, 150); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'center'; ctx.fillText('0.68 km', 0, 0); ctx.restore(); } }, [viewMode, isProcessing, isWire, isPoint]); return (
{viewMode === '3d' && !isProcessing && (
0mm
3.2mm
)} {isProcessing && (
재구성 처리중...
)}
); } export function SensorAnalysis() { const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel') const [viewMode, setViewMode] = useState('3d') const [selectedItem, setSelectedItem] = useState(reconItems[1]) const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution')) return (
{/* Left Panel */}
{/* 3D Reconstruction List */}
📋 3D 재구성 완료 목록
{filteredItems.map(item => (
setSelectedItem(item)} className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${ selectedItem.id === item.id ? 'bg-[rgba(6,182,212,0.08)] border-primary-cyan/20' : 'border-transparent hover:bg-white/[0.02]' }`} >
{item.name}
{item.id} · {item.points} pts
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
))}
{/* Source Images */}
📹 촬영 원본
{[ { label: 'D-01 정면', sensor: '광학', color: 'text-primary-blue' }, { label: 'D-02 좌현', sensor: 'IR', color: 'text-status-red' }, { label: 'D-03 우현', sensor: '광학', color: 'text-primary-purple' }, { label: 'D-02 상부', sensor: 'IR', color: 'text-status-red' }, ].map((src, i) => (
{src.label.split(' ')[0]}
{src.label} {src.sensor}
))}
{/* Center Panel - 3D Canvas */}
{/* Simulated 3D viewport */}
{/* Grid floor */}
{/* 3D Model Visualization */} {selectedItem.type === 'vessel' ? ( ) : ( )} {/* Axis indicator */}
X →
Y ↑
Z ⊙
{/* Title */}
3D Vessel Analysis
{selectedItem.name} 정밀분석
34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}
{/* View Mode Buttons */}
{[ { id: '3d', label: '3D모델' }, { id: 'point', label: '포인트클라우드' }, { id: 'wire', label: '와이어프레임' }, ].map(m => ( ))}
{/* Bottom Stats */}
{[ { value: selectedItem.points, label: '포인트' }, { value: selectedItem.polygons, label: '폴리곤' }, { value: '3', label: '시점' }, { value: selectedItem.coverage, label: '커버리지' }, { value: '0.023m', label: 'RMS오차' }, ].map((s, i) => (
{s.value}
{s.label}
))}
{/* Right Panel - Analysis Details */}
{/* Ship/Pollution Info */}
📊 분석 정보
{(selectedItem.type === 'vessel' ? [ ['대상', selectedItem.name], ['선종 추정', '일반화물선 (추정)'], ['길이', '약 85m'], ['폭', '약 14m'], ['AIS 상태', 'OFF (미식별)'], ['최초 탐지', '2026-01-18 14:20'], ['촬영 시점', '3 시점 (정면/좌현/우현)'], ['센서', '광학 4K + IR 열화상'], ] : [ ['대상', selectedItem.name], ['유형', '유류 오염'], ['추정 면적', '0.42 km²'], ['추정 유출량', '12.6 kL'], ['유종', 'B-C유 (추정)'], ['최초 탐지', '2026-01-18 13:50'], ['확산 속도', '0.3 km/h (ESE 방향)'], ]).map(([k, v], i) => (
{k} {v}
))}
{/* AI Detection Results */}
🤖 AI 탐지 결과
{(selectedItem.type === 'vessel' ? [ { label: '선박 식별', confidence: 94, color: 'bg-status-green' }, { label: '선종 분류', confidence: 78, color: 'bg-status-yellow' }, { label: '손상 감지', confidence: 45, color: 'bg-status-orange' }, { label: '화물 분석', confidence: 62, color: 'bg-status-yellow' }, ] : [ { label: '유막 탐지', confidence: 97, color: 'bg-status-green' }, { label: '유종 분류', confidence: 85, color: 'bg-status-green' }, { label: '두께 추정', confidence: 72, color: 'bg-status-yellow' }, { label: '확산 예측', confidence: 68, color: 'bg-status-orange' }, ]).map((r, i) => (
{r.label} {r.confidence}%
))}
{/* Comparison / Measurements */}
📐 3D 측정값
{(selectedItem.type === 'vessel' ? [ ['전장 (LOA)', '84.7 m'], ['형폭 (Breadth)', '14.2 m'], ['건현 (Freeboard)', '3.8 m'], ['흘수 (Draft)', '5.6 m (추정)'], ['마스트 높이', '22.3 m'], ] : [ ['유막 면적', '0.42 km²'], ['최대 길이', '1.24 km'], ['최대 폭', '0.68 km'], ['평균 두께', '0.8 mm'], ['최대 두께', '3.2 mm'], ]).map(([k, v], i) => (
{k} {v}
))}
{/* Action Buttons */}
) }