import { useRef, useEffect, useState, useMemo } 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; }; } const VESSEL_TILT_X = (17 * Math.PI) / 180; const POLLUTION_TILT_X = (40 * Math.PI) / 180; // ───────────────────────────────────────────── // Vessel3DModel // ───────────────────────────────────────────── interface VesselPoint { x: number; y: number; z: number; r: number; g: number; b: number; radius: number; } interface VesselEdge { a: number; b: number; r: number; g: number; bCh: number; isWaterline: boolean; } interface VesselGeometry { points: VesselPoint[]; edges: VesselEdge[]; } function buildVesselGeometry(): VesselGeometry { const rand = mulberry32(42); const points: VesselPoint[] = []; const edges: VesselEdge[] = []; // halfWidth: X축 위치에 따른 선체 단면 반폭 const halfWidth = (nx: number): number => { const t = (nx + 1) / 2; // 0~1 (0=선미, 1=선수) if (t > 0.85) return 0.05 + ((1 - t) / 0.15) * 0.18; // 선수: 뾰족 if (t < 0.1) return 0.05 + (t / 0.1) * 0.22; // 선미: 약간 좁음 return 0.22 + Math.sin(((t - 0.1) / 0.75) * Math.PI) * 0.08; // 중앙부 불룩 }; // 선체 구조점: 20 단면 × 8 둘레점 const sections = 20; const circPts = 8; const hullPtStart = 0; for (let si = 0; si < sections; si++) { const nx = -1 + (si / (sections - 1)) * 2; const hw = halfWidth(nx); for (let ci = 0; ci < circPts; ci++) { const t = (ci / circPts) * Math.PI; // 0~PI (갑판~용골) const py = -0.15 - 0.12 * Math.sin(t); const pz = hw * Math.cos(t); points.push({ x: nx, y: py, z: pz, r: 6, g: 182, b: 212, radius: 1.0 }); } } // 선체 표면 랜덤점 2000개 for (let i = 0; i < 2000; i++) { const nx = -1 + rand() * 2; const hw = halfWidth(nx); const t = rand() * Math.PI; const py = -0.15 - 0.12 * Math.sin(t) + (rand() - 0.5) * 0.01; const pz = hw * Math.cos(t) + (rand() - 0.5) * 0.01; points.push({ x: nx, y: py, z: pz, r: 6, g: 182, b: 212, radius: 0.6 }); } // 갑판 (y=-0.03) 200개 for (let i = 0; i < 200; i++) { const nx = -1 + rand() * 2; const hw = halfWidth(nx) * 0.85; const pz = (rand() * 2 - 1) * hw; points.push({ x: nx, y: -0.03, z: pz, r: 6, g: 182, b: 212, radius: 0.7 }); } // Bridge: 선교 (-0.6 < x < -0.3, -0.03 < y < 0.17) for (let i = 0; i < 150; i++) { const bx = -0.6 + rand() * 0.3; const by = -0.03 + rand() * 0.2; const bz = (rand() * 2 - 1) * 0.12; points.push({ x: bx, y: by, z: bz, r: 6, g: 182, b: 212, radius: 0.8 }); } // Funnel: 연통 원통 (x=-0.5, y=0.17~0.35) for (let i = 0; i < 40; i++) { const angle = rand() * Math.PI * 2; const fy = 0.17 + rand() * 0.18; points.push({ x: -0.5 + (rand() - 0.5) * 0.04, y: fy, z: 0.04 * Math.cos(angle), r: 239, g: 68, b: 68, radius: 1.0 }); } // Wireframe edges: 선체 종방향 (매 2번째 단면) for (let ci = 0; ci < circPts; ci++) { for (let si = 0; si < sections - 2; si += 2) { edges.push({ a: hullPtStart + si * circPts + ci, b: hullPtStart + (si + 2) * circPts + ci, r: 6, g: 182, bCh: 212, isWaterline: false, }); } } // Wireframe edges: 횡방향 (매 4번째 단면) for (let si = 0; si < sections; si += 4) { for (let ci = 0; ci < circPts - 1; ci++) { edges.push({ a: hullPtStart + si * circPts + ci, b: hullPtStart + si * circPts + ci + 1, r: 6, g: 182, bCh: 212, isWaterline: false, }); } edges.push({ a: hullPtStart + si * circPts + circPts - 1, b: hullPtStart + si * circPts, r: 6, g: 182, bCh: 212, isWaterline: false, }); } // 수선 (waterline) const wlSections = [4, 6, 8, 10, 12, 14, 16]; wlSections.forEach(si => { if (si < sections - 1) { edges.push({ a: hullPtStart + si * circPts, b: hullPtStart + (si + 1) * circPts, r: 6, g: 182, bCh: 212, isWaterline: true, }); } }); // Crane 포인트 const craneData = [ { x: 0.1, y1: -0.03, y2: 0.18 }, { x: 0.3, y1: -0.03, y2: 0.15 }, ]; craneData.forEach(cr => { for (let i = 0; i < 20; i++) { const t = i / 19; points.push({ x: cr.x, y: cr.y1 + t * (cr.y2 - cr.y1), z: 0, r: 249, g: 115, b: 22, radius: 1.2 }); } for (let i = 0; i < 12; i++) { const t = i / 11; points.push({ x: cr.x + t * (-0.08), y: cr.y2 - t * 0.1, z: 0, r: 249, g: 115, b: 22, radius: 1.2 }); } }); // Mast for (let i = 0; i < 15; i++) { const t = i / 14; points.push({ x: -0.45, y: 0.17 + t * 0.15, z: 0, r: 6, g: 182, b: 212, radius: 1.0 }); } return { points, edges }; } function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) { const isProcessing = status === 'processing'; const canvasRef = useRef(null); const angleRef = useRef(0); const rafRef = useRef(0); const W = 480; const H = 280; const FOV = 3.5; const geo = useMemo(() => buildVesselGeometry(), []); 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); const cx = W / 2; const cy = H / 2 + 10; const scale3d = 155; const project = (px: number, py: number, pz: number, angle: number): { sx: number; sy: number; sc: number } => { const cosA = Math.cos(angle); const sinA = Math.sin(angle); const rx = px * cosA - pz * sinA; const rz = px * sinA + pz * cosA; const cosT = Math.cos(VESSEL_TILT_X); const sinT = Math.sin(VESSEL_TILT_X); const ry2 = py * cosT - rz * sinT; const rz2 = py * sinT + rz * cosT; const sc = FOV / (FOV + rz2 + 1.5); return { sx: cx + rx * scale3d * sc, sy: cy - ry2 * scale3d * sc, sc }; }; const render = () => { ctx.clearRect(0, 0, W, H); const angle = angleRef.current; const alphaBase = isProcessing ? 0.25 : 1.0; const showPoints = viewMode === 'point' || viewMode === '3d'; const showWire = viewMode === 'wire' || viewMode === '3d'; // 에지 (wireframe) if (showWire) { geo.edges.forEach(edge => { const ptA = geo.points[edge.a]; const ptB = geo.points[edge.b]; if (!ptA || !ptB) return; const projA = project(ptA.x, ptA.y, ptA.z, angle); const projB = project(ptB.x, ptB.y, ptB.z, angle); const avgSc = (projA.sc + projB.sc) / 2; const brightness = 0.3 + avgSc * 0.5; const lineAlpha = (edge.isWaterline ? 0.25 : viewMode === 'wire' ? 0.6 : 0.3) * alphaBase * brightness; ctx.beginPath(); ctx.moveTo(projA.sx, projA.sy); ctx.lineTo(projB.sx, projB.sy); ctx.setLineDash(edge.isWaterline ? [3, 2] : []); ctx.strokeStyle = `rgba(${edge.r},${edge.g},${edge.bCh},${lineAlpha})`; ctx.lineWidth = viewMode === 'wire' ? 0.8 : 0.5; ctx.stroke(); }); ctx.setLineDash([]); } // 포인트 (back-to-front 정렬) if (showPoints) { const projected = geo.points.map(pt => { const { sx, sy, sc } = project(pt.x, pt.y, pt.z, angle); return { sx, sy, sc, pt }; }); projected.sort((a, b) => a.sc - b.sc); projected.forEach(({ sx, sy, sc, pt }) => { const brightness = 0.4 + sc * 0.8; const ptAlpha = (viewMode === 'point' ? 0.75 : 0.5) * alphaBase * brightness; const ptRadius = (viewMode === 'point' ? pt.radius * 1.4 : pt.radius * 0.8) * sc; ctx.beginPath(); ctx.arc(sx, sy, Math.max(0.3, ptRadius), 0, Math.PI * 2); ctx.fillStyle = `rgba(${pt.r},${pt.g},${pt.b},${ptAlpha})`; ctx.fill(); }); } // Labels (3d 모드, !isProcessing) if (viewMode === '3d' && !isProcessing) { const bowProj = project(1.0, 0, 0, angle); const sternProj = project(-1.0, 0, 0, angle); ctx.font = '8px var(--fM, monospace)'; ctx.fillStyle = 'rgba(6,182,212,0.5)'; ctx.fillText('선수', bowProj.sx + 4, bowProj.sy); ctx.fillText('선미', sternProj.sx - 20, sternProj.sy); ctx.fillStyle = 'rgba(34,197,94,0.7)'; ctx.textAlign = 'center'; ctx.fillText('84.7m', cx, H - 12); ctx.textAlign = 'left'; } angleRef.current += 0.006; rafRef.current = requestAnimationFrame(render); }; render(); return () => { cancelAnimationFrame(rafRef.current); }; }, [viewMode, isProcessing, geo]); return (
{isProcessing && (
재구성 처리중...
)}
); } // ───────────────────────────────────────────── // Pollution3DModel // ───────────────────────────────────────────── interface PollutionPoint { x: number; y: number; z: number; r: number; g: number; b: number; radius: number; } interface PollutionEdge { points: Array<{ x: number; y: number; z: number }>; r: number; g: number; b: number; isDash: boolean; } interface PollutionGeometry { points: PollutionPoint[]; edges: PollutionEdge[]; } function buildPollutionGeometry(): PollutionGeometry { const rand = mulberry32(99); const points: PollutionPoint[] = []; const edges: PollutionEdge[] = []; // 해수면 그리드: y=0 평면, 15×15 격자 (-1.5~1.5) const gridCount = 15; const gridRange = 1.5; const gridStep = (gridRange * 2) / gridCount; for (let i = 0; i <= gridCount; i++) { const pos = -gridRange + i * gridStep; edges.push({ points: [{ x: -gridRange, y: 0, z: pos }, { x: gridRange, y: 0, z: pos }], r: 6, g: 182, b: 212, isDash: false, }); edges.push({ points: [{ x: pos, y: 0, z: -gridRange }, { x: pos, y: 0, z: gridRange }], r: 6, g: 182, b: 212, isDash: false, }); } // 오염 blob 포인트 3000개: 불규칙 타원형 const rx = 1.0; const rz = 0.7; for (let i = 0; i < 3000; i++) { const angle = rand() * Math.PI * 2; const distR = Math.sqrt(rand()); // 균일 면적 분포 const px = distR * rx * Math.cos(angle) * (1 + (rand() - 0.5) * 0.15); const pz = distR * rz * Math.sin(angle) * (1 + (rand() - 0.5) * 0.15); const dist = Math.sqrt((px / rx) ** 2 + (pz / rz) ** 2); if (dist > 1.0) continue; let py: number; let pr: number; let pg: number; let pb: number; if (dist < 0.3) { // 중심: 두꺼운 적색 py = (rand() - 0.5) * 0.16; pr = 239; pg = 68; pb = 68; } else if (dist < 0.6) { // 중간: 주황 py = (rand() - 0.5) * 0.08; pr = 249; pg = 115; pb = 22; } else { // 외곽: 노랑, y~0 py = (rand() - 0.5) * 0.02; pr = 234; pg = 179; pb = 8; } points.push({ x: px, y: py, z: pz, r: pr, g: pg, b: pb, radius: 0.7 + rand() * 0.5 }); } // 등고선 에지: 3개 동심 타원 (dist=0.3, 0.6, 0.9) const contours = [ { dr: 0.3, r: 239, g: 68, b: 68 }, { dr: 0.6, r: 249, g: 115, b: 22 }, { dr: 0.9, r: 234, g: 179, b: 8 }, ]; contours.forEach(({ dr, r, g, b }) => { const pts: Array<{ x: number; y: number; z: number }> = []; const N = 48; for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; pts.push({ x: dr * rx * Math.cos(a), y: 0, z: dr * rz * Math.sin(a) }); } edges.push({ points: pts, r, g, b, isDash: true }); }); // 외곽 경계 에지 (불규칙 타원) const outerPts: Array<{ x: number; y: number; z: number }> = []; const N = 64; for (let i = 0; i <= N; i++) { const a = (i / N) * Math.PI * 2; const noise = 1 + (Math.sin(a * 3.7) * 0.08 + Math.sin(a * 7.1) * 0.05); outerPts.push({ x: rx * noise * Math.cos(a), y: 0, z: rz * noise * Math.sin(a) }); } edges.push({ points: outerPts, r: 239, g: 68, b: 68, isDash: false }); // 확산 방향 화살표 에지 (ESE 방향) const arrowAngle = -0.3; // ESE const arrowLen = 1.2; const ax = Math.cos(arrowAngle) * arrowLen; const az = Math.sin(arrowAngle) * arrowLen; edges.push({ points: [{ x: 0.5, y: 0, z: 0.2 }, { x: 0.5 + ax, y: 0, z: 0.2 + az }], r: 249, g: 115, b: 22, isDash: false }); return { points, edges }; } function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) { const isProcessing = status === 'processing'; const canvasRef = useRef(null); const angleRef = useRef(0); const rafRef = useRef(0); const W = 420; const H = 300; const FOV = 4.0; const geo = useMemo(() => buildPollutionGeometry(), []); 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); const cx = W / 2; const cy = H / 2 + 20; const scale3d = 100; const project = (px: number, py: number, pz: number, angle: number): { sx: number; sy: number; sc: number } => { const cosA = Math.cos(angle); const sinA = Math.sin(angle); const rx = px * cosA - pz * sinA; const rz = px * sinA + pz * cosA; const cosT = Math.cos(POLLUTION_TILT_X); const sinT = Math.sin(POLLUTION_TILT_X); const ry2 = py * cosT - rz * sinT; const rz2 = py * sinT + rz * cosT; const sc = FOV / (FOV + rz2 + 1.5); return { sx: cx + rx * scale3d * sc, sy: cy - ry2 * scale3d * sc, sc }; }; const render = () => { ctx.clearRect(0, 0, W, H); const angle = angleRef.current; const alphaBase = isProcessing ? 0.25 : 1.0; const showPoints = viewMode === 'point' || viewMode === '3d'; const showWire = viewMode === 'wire' || viewMode === '3d'; // 에지 렌더링 if (showWire) { geo.edges.forEach(edge => { if (edge.points.length < 2) return; // 그리드는 매우 얇게 const isGrid = !edge.isDash && edge.r === 6 && edge.b === 212; const lineAlpha = isGrid ? 0.06 * alphaBase : edge.isDash ? 0.35 * alphaBase : 0.55 * alphaBase; ctx.beginPath(); const first = project(edge.points[0].x, edge.points[0].y, edge.points[0].z, angle); ctx.moveTo(first.sx, first.sy); for (let i = 1; i < edge.points.length; i++) { const p = project(edge.points[i].x, edge.points[i].y, edge.points[i].z, angle); ctx.lineTo(p.sx, p.sy); } ctx.setLineDash(edge.isDash ? [3, 2] : []); ctx.strokeStyle = `rgba(${edge.r},${edge.g},${edge.b},${lineAlpha})`; ctx.lineWidth = isGrid ? 0.4 : 0.8; ctx.stroke(); }); ctx.setLineDash([]); } // 포인트 (back-to-front 정렬) if (showPoints) { const projected = geo.points.map(pt => { const { sx, sy, sc } = project(pt.x, pt.y, pt.z, angle); return { sx, sy, sc, pt }; }); projected.sort((a, b) => a.sc - b.sc); projected.forEach(({ sx, sy, sc, pt }) => { const brightness = 0.4 + sc * 0.7; const ptAlpha = (viewMode === 'point' ? 0.8 : 0.55) * alphaBase * brightness; const ptRadius = (viewMode === 'point' ? pt.radius * 1.3 : pt.radius * 0.9) * sc; ctx.beginPath(); ctx.arc(sx, sy, Math.max(0.3, ptRadius), 0, Math.PI * 2); ctx.fillStyle = `rgba(${pt.r},${pt.g},${pt.b},${ptAlpha})`; ctx.fill(); }); } // Labels (3d 모드, !isProcessing) if (viewMode === '3d' && !isProcessing) { const p30 = project(0, 0.08, 0, angle); const p60 = project(0, 0.04, 0.45, angle); const p90 = project(0, 0, 0.8, angle); ctx.font = '7px var(--fM, monospace)'; ctx.fillStyle = 'rgba(239,68,68,0.75)'; ctx.fillText('3.2mm', p30.sx + 3, p30.sy); ctx.fillStyle = 'rgba(249,115,22,0.65)'; ctx.fillText('1.5mm', p60.sx + 3, p60.sy); ctx.fillStyle = 'rgba(234,179,8,0.6)'; ctx.fillText('0.3mm', p90.sx + 3, p90.sy); ctx.fillStyle = 'rgba(34,197,94,0.7)'; ctx.textAlign = 'center'; ctx.fillText('1.24 km', cx, H - 10); ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(249,115,22,0.6)'; ctx.fillText('ESE 0.3km/h', cx + 55, cy - 30); } angleRef.current += 0.006; rafRef.current = requestAnimationFrame(render); }; render(); return () => { cancelAnimationFrame(rafRef.current); }; }, [viewMode, isProcessing, geo]); 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 */}
) }