wing-ops/frontend/src/tabs/aerial/components/SensorAnalysis.tsx

1048 lines
35 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-color-accent/40 text-caption font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
<div
className="h-full bg-[rgba(6,182,212,0.4)] 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(--fg-disabled)', fontFamily: 'var(--font-mono)' }}
>
<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-color-danger/40 text-caption font-mono animate-pulse">
...
</div>
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
<div
className="h-full bg-[rgba(239,68,68,0.4)] 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-surface border-r border-stroke flex flex-col overflow-auto">
{/* 3D Reconstruction List */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-caption font-bold text-fg-disabled 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-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'vessel'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
: 'text-fg-disabled bg-bg-base border-stroke'
}`}
>
🚢
</button>
<button
onClick={() => setSubTab('pollution')}
className={`flex-1 py-1.5 text-center text-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'pollution'
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
: 'text-fg-disabled bg-bg-base border-stroke'
}`}
>
🛢
</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-[rgba(6,182,212,0.2)]'
: 'border-transparent hover:bg-white/[0.02]'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-caption font-bold text-fg font-korean">{item.name}</div>
<div className="text-caption text-fg-disabled font-mono">
{item.id} · {item.points} pts
</div>
</div>
<span
className={`text-caption font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
>
{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-caption font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
📹
</div>
<div className="grid grid-cols-2 gap-1">
{[
{ label: 'D-01 정면', sensor: '광학', color: 'text-fg-sub' },
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-fg-sub' },
{ label: 'D-03 우현', sensor: '광학', color: 'text-fg-sub' },
{ label: 'D-02 상부', sensor: 'IR', color: 'text-fg-sub' },
].map((src, i) => (
<div
key={i}
className="relative rounded-sm bg-bg-base border border-stroke overflow-hidden aspect-square"
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'var(--bg-base)' }}
>
<div className="text-fg-disabled/10 text-caption 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-caption text-fg-disabled 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-base border-x border-stroke 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(--font-mono)' }}
>
<div style={{ color: 'var(--color-danger)' }}>X </div>
<div className="text-green-500">Y </div>
<div className="text-blue-500">Z </div>
</div>
</div>
{/* Title */}
<div className="absolute top-3 left-3 z-[2]">
<div className="text-caption font-bold text-fg-disabled uppercase tracking-wider">
3D Vessel Analysis
</div>
<div className="text-title-4 font-bold text-color-accent my-1 font-korean">
{selectedItem.name}
</div>
<div className="text-caption text-fg-disabled 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-caption font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
viewMode === m.id
? 'bg-[rgba(6,182,212,0.2)] border-[rgba(6,182,212,0.5)] text-color-accent'
: 'bg-black/40 border-[rgba(6,182,212,0.2)] text-fg-disabled hover:bg-black/60 hover:border-[rgba(6,182,212,0.4)]'
}`}
>
{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: 'var(--stroke-default)' }}
>
{[
{ 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-body-2 text-color-accent">{s.value}</div>
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
</div>
{/* Right Panel - Analysis Details */}
<div className="w-[270px] bg-bg-surface border-l border-stroke flex flex-col overflow-auto">
{/* Ship/Pollution Info */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📊
</div>
<div className="flex flex-col gap-1.5 text-caption">
{(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-fg-disabled font-korean">{k}</span>
<span className="font-mono font-semibold text-fg text-right ml-2">{v}</span>
</div>
))}
</div>
</div>
{/* AI Detection Results */}
<div className="p-2.5 px-3 border-b border-stroke">
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
🤖 AI
</div>
<div className="flex flex-col gap-1">
{(selectedItem.type === 'vessel'
? [
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
{ label: '선종 분류', confidence: 78, color: 'bg-color-success' },
{ label: '손상 감지', confidence: 45, color: 'bg-color-success' },
{ label: '화물 분석', confidence: 62, color: 'bg-color-success' },
]
: [
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
{ label: '두께 추정', confidence: 72, color: 'bg-color-success' },
{ label: '확산 예측', confidence: 68, color: 'bg-color-success' },
]
).map((r, i) => (
<div key={i}>
<div className="flex justify-between text-caption mb-0.5">
<span className="text-fg-disabled font-korean">{r.label}</span>
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
</div>
<div className="w-full h-1 bg-bg-base 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-stroke">
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
📐 3D
</div>
<div className="flex flex-col gap-1 text-caption">
{(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-base rounded">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono font-semibold text-fg">{v}</span>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="p-2.5 px-3">
<button
className="w-full py-2.5 rounded-sm text-label-2 font-semibold font-korean text-color-accent border cursor-pointer mb-2 transition-colors"
style={{
border: '1px solid rgba(6,182,212,.3)',
background: 'rgba(6,182,212,.08)',
}}
>
📊
</button>
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-label-2 font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
📥 3D
</button>
</div>
</div>
</div>
);
}