- 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>
837 lines
32 KiB
TypeScript
837 lines
32 KiB
TypeScript
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>
|
||
)
|
||
}
|