wing-ops/frontend/src/tabs/aerial/components/SensorAnalysis.tsx
htlee 6356b0a3bd feat(frontend): 항공탐색 탭 개선 + 확산분석 데모 데이터 시각화
항공탐색 탭:
- CctvView 크래시 수정 (cctvCameras → cameras 필드 매핑)
- AerialView 이중 서브메뉴 분기 → 플랫 switch 단순화
- SensorAnalysis SVG 300pt → Canvas 2D 5000/8000pt 고밀도 전환
- RealtimeDrone CSS 시뮬레이션 → MapLibre + deck.gl 실제 지도 전환

확산분석 탭:
- 시뮬레이션 백엔드 미구현 시 클라이언트 데모 궤적 fallback 생성
- AI 방어선 3개(직교차단/U형포위/연안보호) 자동 배치
- 민감자원 5개소(양식장/해수욕장/보호구역) deck.gl 레이어 표시
- 해류 화살표 11x11 그리드 TextLayer 추가

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

750 lines
29 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLCanvasElement>(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 (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(15deg) rotateY(-25deg) rotateZ(2deg)', transformStyle: 'preserve-3d', position: 'relative', width: `${W}px`, height: `${H}px` }}>
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse"> ...</div>
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
</div>
</div>
</div>
)}
</div>
</div>
);
}
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
const isProcessing = status === 'processing';
const isWire = viewMode === 'wire';
const isPoint = viewMode === 'point';
const canvasRef = useRef<HTMLCanvasElement>(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 (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: `${W}px`, height: `${H}px` }}>
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
{viewMode === '3d' && !isProcessing && (
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
<span>0mm</span>
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
<span>3.2mm</span>
</div>
)}
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-status-red/40 text-xs font-mono animate-pulse"> ...</div>
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-status-red/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
</div>
</div>
</div>
)}
</div>
</div>
);
}
export function SensorAnalysis() {
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel')
const [viewMode, setViewMode] = useState('3d')
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1])
const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'))
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* Left Panel */}
<div className="w-[280px] bg-bg-1 border-r border-border flex flex-col overflow-auto">
{/* 3D Reconstruction List */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📋 3D </div>
<div className="flex gap-1 mb-2">
<button
onClick={() => setSubTab('vessel')}
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'vessel'
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
: 'text-text-3 bg-bg-0 border-border'
}`}
>
🚢
</button>
<button
onClick={() => setSubTab('pollution')}
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'pollution'
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
: 'text-text-3 bg-bg-0 border-border'
}`}
>
🛢
</button>
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
<div
key={item.id}
onClick={() => 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]'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold text-text-1 font-korean">{item.name}</div>
<div className="text-[8px] text-text-3 font-mono">{item.id} · {item.points} pts</div>
</div>
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-status-green' : 'text-status-orange'}`}>
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
</span>
</div>
))}
</div>
</div>
{/* Source Images */}
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📹 </div>
<div className="grid grid-cols-2 gap-1">
{[
{ 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) => (
<div key={i} className="relative rounded-sm bg-bg-0 border border-border overflow-hidden aspect-square">
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
<div className="text-text-3/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-text-3 font-korean">
<span>{src.label}</span>
<span className={src.color}>{src.sensor}</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Center Panel - 3D Canvas */}
<div className="flex-1 relative bg-bg-0 border-x border-border flex items-center justify-center overflow-hidden">
{/* Simulated 3D viewport */}
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
{/* Grid floor */}
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)', backgroundSize: '40px 40px', transform: 'perspective(500px) rotateX(55deg)', transformOrigin: 'center 80%' }} />
{/* 3D Model Visualization */}
{selectedItem.type === 'vessel' ? (
<Vessel3DModel viewMode={viewMode} status={selectedItem.status} />
) : (
<Pollution3DModel viewMode={viewMode} status={selectedItem.status} />
)}
{/* Axis indicator */}
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--fM)' }}>
<div style={{ color: '#ef4444' }}>X </div>
<div style={{ color: '#22c55e' }}>Y </div>
<div style={{ color: '#3b82f6' }}>Z </div>
</div>
</div>
{/* Title */}
<div className="absolute top-3 left-3 z-[2]">
<div className="text-[10px] font-bold text-text-3 uppercase tracking-wider">3D Vessel Analysis</div>
<div className="text-[13px] font-bold text-primary-cyan my-1 font-korean">{selectedItem.name} </div>
<div className="text-[9px] text-text-3 font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
</div>
{/* View Mode Buttons */}
<div className="absolute top-3 right-3 flex gap-1 z-[2]">
{[
{ id: '3d', label: '3D모델' },
{ id: 'point', label: '포인트클라우드' },
{ id: 'wire', label: '와이어프레임' },
].map(m => (
<button
key={m.id}
onClick={() => setViewMode(m.id)}
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
viewMode === m.id
? 'bg-[rgba(6,182,212,0.2)] border-primary-cyan/50 text-primary-cyan'
: 'bg-black/40 border-primary-cyan/20 text-text-3 hover:bg-black/60 hover:border-primary-cyan/40'
}`}
>
{m.label}
</button>
))}
</div>
{/* Bottom Stats */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
{[
{ value: selectedItem.points, label: '포인트' },
{ value: selectedItem.polygons, label: '폴리곤' },
{ value: '3', label: '시점' },
{ value: selectedItem.coverage, label: '커버리지' },
{ value: '0.023m', label: 'RMS오차' },
].map((s, i) => (
<div key={i} className="text-center">
<div className="font-mono font-bold text-sm text-primary-cyan">{s.value}</div>
<div className="text-[8px] text-text-3 mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
</div>
{/* Right Panel - Analysis Details */}
<div className="w-[270px] bg-bg-1 border-l border-border flex flex-col overflow-auto">
{/* Ship/Pollution Info */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📊 </div>
<div className="flex flex-col gap-1.5 text-[10px]">
{(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) => (
<div key={i} className="flex justify-between items-start">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-text-1 text-right ml-2">{v}</span>
</div>
))}
</div>
</div>
{/* AI Detection Results */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">🤖 AI </div>
<div className="flex flex-col gap-1">
{(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) => (
<div key={i}>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-text-3 font-korean">{r.label}</span>
<span className="font-mono font-semibold text-text-1">{r.confidence}%</span>
</div>
<div className="w-full h-1 bg-bg-0 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
</div>
</div>
))}
</div>
</div>
{/* Comparison / Measurements */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📐 3D </div>
<div className="flex flex-col gap-1 text-[10px]">
{(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) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-primary-cyan">{v}</span>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="p-2.5 px-3">
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
📊
</button>
<button className="w-full py-2 border border-border bg-bg-3 text-text-2 rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
📥 3D
</button>
</div>
</div>
</div>
)
}