wing-ops/frontend/src/tabs/aerial/components/SensorAnalysis.tsx
htlee 86b4a03c12 feat(frontend): TextLayer 한글 폰트 + 확산분석 자동 데모 + 3D 회전 애니메이션
- TextLayer: characterSet 'auto', NanumSquare 한글 폰트, outline 설정 (MapView, RealtimeDrone)
- 확산분석 탭: 진입 시 데모 궤적/방어선/민감자원 자동 로드 (useEffect + handleSelectAnalysis)
- SensorAnalysis: Vessel/Pollution 3DModel을 requestAnimationFrame 기반 360° 회전으로 전환
  - 3D geometry useMemo 생성, Y축 회전 + X축 틸트, depth 기반 크기/밝기

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

837 lines
32 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.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLCanvasElement>(null);
const angleRef = useRef(0);
const rafRef = useRef<number>(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 (
<div className="absolute inset-0 flex items-center justify-center">
<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>
);
}
// ─────────────────────────────────────────────
// 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<HTMLCanvasElement>(null);
const angleRef = useRef(0);
const rafRef = useRef<number>(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 (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={canvasRef} style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }} />
{viewMode === '3d' && !isProcessing && (
<div className="absolute bottom-2 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>
);
}
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>
)
}