6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
498 lines
25 KiB
TypeScript
498 lines
25 KiB
TypeScript
import { 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 Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||
const isProcessing = status === 'processing'
|
||
const isWire = viewMode === 'wire'
|
||
const isPoint = viewMode === 'point'
|
||
|
||
const [vesselPoints] = useState(() =>
|
||
Array.from({ length: 300 }, (_, i) => {
|
||
const x = 35 + Math.random() * 355
|
||
const y = 15 + Math.random() * 160
|
||
const inHull = y > 60 && y < 175 && x > 35 && x < 390
|
||
const inBridge = x > 260 && x < 330 && y > 25 && y < 60
|
||
if (!inHull && !inBridge && Math.random() > 0.15) return null
|
||
const alpha = 0.15 + Math.random() * 0.55
|
||
const r = 0.8 + Math.random() * 0.8
|
||
return { i, x, y, r, alpha }
|
||
})
|
||
)
|
||
|
||
// 선박 SVG 와이어프레임/솔리드 3D 투시
|
||
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: '420px', height: '200px' }}>
|
||
<svg viewBox="0 0 420 200" width="420" height="200" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||
{/* 수선 (waterline) */}
|
||
<ellipse cx="210" cy="165" rx="200" ry="12" fill="none" stroke="rgba(6,182,212,0.15)" strokeWidth="0.5" strokeDasharray="4 2" />
|
||
|
||
{/* 선체 (hull) - 3D 효과 */}
|
||
<path d="M 30 140 Q 40 170 100 175 L 320 175 Q 380 170 395 140 L 390 100 Q 385 85 370 80 L 50 80 Q 35 85 30 100 Z"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.08)'}
|
||
stroke={isProcessing ? 'rgba(6,182,212,0.2)' : 'rgba(6,182,212,0.5)'}
|
||
strokeWidth={isWire ? '0.8' : '1.2'} />
|
||
|
||
{/* 선체 하부 */}
|
||
<path d="M 30 140 Q 20 155 60 168 L 100 175 M 395 140 Q 405 155 360 168 L 320 175"
|
||
fill="none" stroke="rgba(6,182,212,0.3)" strokeWidth="0.7" />
|
||
|
||
{/* 갑판 (deck) */}
|
||
<path d="M 50 80 Q 45 65 55 60 L 365 60 Q 375 65 370 80"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.05)'}
|
||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.45)'}
|
||
strokeWidth={isWire ? '0.8' : '1'} />
|
||
|
||
{/* 선교 (bridge) */}
|
||
<rect x="260" y="25" width="70" height="35" rx="2"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.1)'}
|
||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.5)'}
|
||
strokeWidth={isWire ? '0.8' : '1'} />
|
||
{/* 선교 창문 */}
|
||
{!isPoint && <g stroke="rgba(6,182,212,0.3)" strokeWidth="0.5" fill="none">
|
||
<rect x="268" y="30" width="10" height="6" rx="1" />
|
||
<rect x="282" y="30" width="10" height="6" rx="1" />
|
||
<rect x="296" y="30" width="10" height="6" rx="1" />
|
||
<rect x="310" y="30" width="10" height="6" rx="1" />
|
||
</g>}
|
||
|
||
{/* 마스트 */}
|
||
<line x1="295" y1="25" x2="295" y2="8" stroke="rgba(6,182,212,0.4)" strokeWidth="1" />
|
||
<line x1="288" y1="12" x2="302" y2="12" stroke="rgba(6,182,212,0.3)" strokeWidth="0.8" />
|
||
|
||
{/* 연통 (funnel) */}
|
||
<rect x="235" y="38" width="18" height="22" rx="1"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.1)'}
|
||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.4)'}
|
||
strokeWidth={isWire ? '0.8' : '1'} />
|
||
|
||
{/* 화물 크레인 */}
|
||
<g stroke={isProcessing ? 'rgba(249,115,22,0.15)' : 'rgba(249,115,22,0.4)'} strokeWidth="0.8" fill="none">
|
||
<line x1="150" y1="60" x2="150" y2="20" />
|
||
<line x1="150" y1="22" x2="120" y2="40" />
|
||
<line x1="180" y1="60" x2="180" y2="25" />
|
||
<line x1="180" y1="27" x2="155" y2="42" />
|
||
</g>
|
||
|
||
{/* 선체 리브 (와이어프레임 / 포인트 모드) */}
|
||
{(isWire || isPoint) && <g stroke="rgba(6,182,212,0.15)" strokeWidth="0.4">
|
||
{[80, 120, 160, 200, 240, 280, 320, 360].map(x => (
|
||
<line key={x} x1={x} y1="60" x2={x} y2="175" />
|
||
))}
|
||
{[80, 100, 120, 140, 160].map(y => (
|
||
<line key={y} x1="30" y1={y} x2="395" y2={y} />
|
||
))}
|
||
</g>}
|
||
|
||
{/* 포인트 클라우드 모드 */}
|
||
{isPoint && <g>
|
||
{vesselPoints.map(p => p && (
|
||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
|
||
))}
|
||
</g>}
|
||
|
||
{/* 선수/선미 표시 */}
|
||
<text x="395" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선수</text>
|
||
<text x="15" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선미</text>
|
||
|
||
{/* 측정선 (3D 모드) */}
|
||
{viewMode === '3d' && <>
|
||
<line x1="30" y1="185" x2="395" y2="185" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||
<text x="200" y="195" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">84.7m</text>
|
||
<line x1="405" y1="60" x2="405" y2="175" stroke="rgba(249,115,22,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||
<text x="415" y="120" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="start" transform="rotate(90, 415, 120)">14.2m</text>
|
||
</>}
|
||
</svg>
|
||
|
||
{/* 처리중 오버레이 */}
|
||
{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 [pollutionPoints] = useState(() =>
|
||
Array.from({ length: 400 }, (_, i) => {
|
||
const cx = 190, cy = 145, rx = 130, ry = 75
|
||
const angle = Math.random() * Math.PI * 2
|
||
const r = Math.sqrt(Math.random())
|
||
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) return null
|
||
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2)
|
||
const intensity = Math.max(0.1, 1 - dist)
|
||
const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})`
|
||
const circleR = 0.6 + Math.random() * 1.2
|
||
return { i, x, y, r: circleR, color }
|
||
})
|
||
)
|
||
|
||
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: '380px', height: '260px' }}>
|
||
<svg viewBox="0 0 380 260" width="380" height="260" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||
{/* 해수면 그리드 */}
|
||
<g stroke="rgba(6,182,212,0.08)" strokeWidth="0.4">
|
||
{Array.from({ length: 15 }, (_, i) => <line key={`h${i}`} x1="0" y1={i * 20} x2="380" y2={i * 20} />)}
|
||
{Array.from({ length: 20 }, (_, i) => <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="260" />)}
|
||
</g>
|
||
|
||
{/* 유막 메인 형태 - 불규칙 blob */}
|
||
<path d="M 120 80 Q 80 90 70 120 Q 55 155 80 180 Q 100 205 140 210 Q 180 220 220 205 Q 270 195 300 170 Q 320 145 310 115 Q 300 85 270 75 Q 240 65 200 70 Q 160 68 120 80 Z"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.08)'}
|
||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.45)'}
|
||
strokeWidth={isWire ? '0.8' : '1.5'} />
|
||
|
||
{/* 유막 두께 등고선 */}
|
||
<path d="M 155 100 Q 125 115 120 140 Q 115 165 135 180 Q 155 195 190 190 Q 230 185 255 165 Q 270 145 260 120 Q 250 100 225 95 Q 195 88 155 100 Z"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(249,115,22,0.08)'}
|
||
stroke={isProcessing ? 'rgba(249,115,22,0.12)' : 'rgba(249,115,22,0.35)'}
|
||
strokeWidth="0.8" strokeDasharray={isWire ? '4 2' : 'none'} />
|
||
|
||
{/* 유막 최고 두께 핵심 */}
|
||
<path d="M 175 120 Q 160 130 165 150 Q 170 170 195 170 Q 220 168 230 150 Q 235 130 220 120 Q 205 110 175 120 Z"
|
||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.15)'}
|
||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.5)'}
|
||
strokeWidth="0.8" />
|
||
|
||
{/* 확산 방향 화살표 */}
|
||
<g stroke="rgba(249,115,22,0.5)" strokeWidth="1" fill="rgba(249,115,22,0.5)">
|
||
<line x1="250" y1="140" x2="330" y2="120" />
|
||
<polygon points="330,120 322,115 324,123" />
|
||
<text x="335" y="122" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)">ESE 0.3km/h</text>
|
||
</g>
|
||
|
||
{/* 와이어프레임 추가 등고선 */}
|
||
{(isWire || isPoint) && <g stroke="rgba(239,68,68,0.12)" strokeWidth="0.3">
|
||
<ellipse cx="190" cy="145" rx="140" ry="80" fill="none" />
|
||
<ellipse cx="190" cy="145" rx="100" ry="55" fill="none" />
|
||
<ellipse cx="190" cy="145" rx="60" ry="35" fill="none" />
|
||
</g>}
|
||
|
||
{/* 포인트 클라우드 */}
|
||
{isPoint && <g>
|
||
{pollutionPoints.map(p => p && (
|
||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
|
||
))}
|
||
</g>}
|
||
|
||
{/* 두께 색상 범례 */}
|
||
{viewMode === '3d' && <>
|
||
<text x="165" y="148" fill="rgba(239,68,68,0.7)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">3.2mm</text>
|
||
<text x="130" y="165" fill="rgba(249,115,22,0.5)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">1.5mm</text>
|
||
<text x="95" y="130" fill="rgba(234,179,8,0.4)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">0.3mm</text>
|
||
</>}
|
||
|
||
{/* 측정선 (3D 모드) */}
|
||
{viewMode === '3d' && <>
|
||
<line x1="55" y1="240" x2="320" y2="240" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||
<text x="187" y="252" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">1.24 km</text>
|
||
<line x1="25" y1="80" x2="25" y2="210" stroke="rgba(59,130,246,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||
<text x="15" y="150" fill="rgba(59,130,246,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle" transform="rotate(-90, 15, 150)">0.68 km</text>
|
||
</>}
|
||
</svg>
|
||
|
||
{/* 두께 색상 범례 바 */}
|
||
{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>
|
||
)
|
||
}
|