develop #54

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-03-01 09:34:45 +09:00
4개의 변경된 파일542개의 추가작업 그리고 415개의 파일을 삭제
Showing only changes of commit 86b4a03c12 - Show all commits

파일 보기

@ -481,13 +481,13 @@ export function MapView({
getSize: 12,
getColor: [255, 255, 255, 200],
getPixelOffset: [0, -20],
fontFamily: 'var(--fK), sans-serif',
fontSettings: { sdf: false },
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200],
billboard: true,
sizeUnits: 'pixels' as const,
background: true,
getBackgroundColor: [15, 21, 36, 180],
backgroundPadding: [4, 2],
})
)
}
@ -520,6 +520,7 @@ export function MapView({
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing,
getSize: 14,
getColor: [6, 182, 212, 70],
characterSet: 'auto',
sizeUnits: 'pixels' as const,
billboard: true,
})

파일 보기

@ -176,8 +176,11 @@ export function RealtimeDrone() {
getText: d => `${d.id}구역`,
getColor: d => [...d.color, 180],
getSize: 12,
fontFamily: 'Noto Sans KR, sans-serif',
fontWeight: 700,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 180],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
billboard: false,
@ -209,8 +212,11 @@ export function RealtimeDrone() {
getText: () => '유류확산',
getColor: [249, 115, 22, 200],
getSize: 11,
fontFamily: 'Noto Sans KR, sans-serif',
fontWeight: 700,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 180],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
billboard: false,
@ -248,8 +254,11 @@ export function RealtimeDrone() {
getText: () => 'HNS 의심',
getColor: [234, 179, 8, 180],
getSize: 10,
fontFamily: 'Noto Sans KR, sans-serif',
fontWeight: 700,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 180],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
billboard: false,
@ -291,8 +300,11 @@ export function RealtimeDrone() {
getText: d => d.name,
getColor: [255, 255, 255, 190],
getSize: 11,
fontFamily: 'Noto Sans KR, sans-serif',
fontWeight: 700,
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 180],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
billboard: false,
@ -398,7 +410,10 @@ export function RealtimeDrone() {
},
getSize: d => selectedDrone === d.id ? 13 : 10,
fontFamily: 'Outfit, monospace',
fontWeight: 700,
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
billboard: false,

파일 보기

@ -1,4 +1,4 @@
import { useRef, useEffect, useState } from 'react';
import { useRef, useEffect, useState, useMemo } from 'react';
interface ReconItem {
id: string
@ -28,14 +28,172 @@ function mulberry32(seed: number) {
};
}
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 isWire = viewMode === 'wire';
const isPoint = viewMode === 'point';
const canvasRef = useRef<HTMLCanvasElement>(null);
const angleRef = useRef(0);
const rafRef = useRef<number>(0);
const W = 420;
const H = 200;
const W = 480;
const H = 280;
const FOV = 3.5;
const geo = useMemo(() => buildVesselGeometry(), []);
useEffect(() => {
const canvas = canvasRef.current;
@ -48,234 +206,243 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const cyanFull = 'rgba(6,182,212,';
const orangeFull = 'rgba(249,115,22,';
const redFull = 'rgba(239,68,68,';
const greenFull = 'rgba(34,197,94,';
const cx = W / 2;
const cy = H / 2 + 10;
const scale3d = 155;
const hullStroke = isProcessing ? `${cyanFull}0.2)` : `${cyanFull}0.5)`;
const hullFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.08)`;
const deckStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.45)`;
const deckFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.05)`;
const bridgeStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.5)`;
const bridgeFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.1)`;
const funnelStroke = isProcessing ? `${redFull}0.15)` : `${redFull}0.4)`;
const funnelFill = isWire || isPoint ? 'transparent' : `${redFull}0.1)`;
const craneStroke = isProcessing ? `${orangeFull}0.15)` : `${orangeFull}0.4)`;
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 };
};
// 수선 (waterline)
ctx.beginPath();
ctx.ellipse(210, 165, 200, 12, 0, 0, Math.PI * 2);
ctx.strokeStyle = `${cyanFull}0.15)`;
ctx.lineWidth = 0.5;
ctx.setLineDash([4, 2]);
ctx.stroke();
ctx.setLineDash([]);
const render = () => {
ctx.clearRect(0, 0, W, H);
const angle = angleRef.current;
const alphaBase = isProcessing ? 0.25 : 1.0;
// 선체 (hull)
ctx.beginPath();
ctx.moveTo(30, 140);
ctx.quadraticCurveTo(40, 170, 100, 175);
ctx.lineTo(320, 175);
ctx.quadraticCurveTo(380, 170, 395, 140);
ctx.lineTo(390, 100);
ctx.quadraticCurveTo(385, 85, 370, 80);
ctx.lineTo(50, 80);
ctx.quadraticCurveTo(35, 85, 30, 100);
ctx.closePath();
ctx.fillStyle = hullFill;
ctx.fill();
ctx.strokeStyle = hullStroke;
ctx.lineWidth = isWire ? 0.8 : 1.2;
ctx.stroke();
const showPoints = viewMode === 'point' || viewMode === '3d';
const showWire = viewMode === 'wire' || viewMode === '3d';
// 선체 하부
ctx.beginPath();
ctx.moveTo(30, 140);
ctx.quadraticCurveTo(20, 155, 60, 168);
ctx.lineTo(100, 175);
ctx.moveTo(395, 140);
ctx.quadraticCurveTo(405, 155, 360, 168);
ctx.lineTo(320, 175);
ctx.strokeStyle = `${cyanFull}0.3)`;
ctx.lineWidth = 0.7;
ctx.stroke();
// 갑판 (deck)
ctx.beginPath();
ctx.moveTo(50, 80);
ctx.quadraticCurveTo(45, 65, 55, 60);
ctx.lineTo(365, 60);
ctx.quadraticCurveTo(375, 65, 370, 80);
ctx.fillStyle = deckFill;
ctx.fill();
ctx.strokeStyle = deckStroke;
ctx.lineWidth = isWire ? 0.8 : 1;
ctx.stroke();
// 선체 리브 (와이어프레임 / 포인트 모드)
if (isWire || isPoint) {
ctx.strokeStyle = `${cyanFull}0.15)`;
ctx.lineWidth = 0.4;
[80, 120, 160, 200, 240, 280, 320, 360].forEach(x => {
ctx.beginPath();
ctx.moveTo(x, 60);
ctx.lineTo(x, 175);
ctx.stroke();
});
[80, 100, 120, 140, 160].forEach(y => {
ctx.beginPath();
ctx.moveTo(30, y);
ctx.lineTo(395, y);
ctx.stroke();
});
}
// 선교 (bridge)
ctx.beginPath();
ctx.roundRect(260, 25, 70, 35, 2);
ctx.fillStyle = bridgeFill;
ctx.fill();
ctx.strokeStyle = bridgeStroke;
ctx.lineWidth = isWire ? 0.8 : 1;
ctx.stroke();
// 선교 창문
if (!isPoint) {
ctx.strokeStyle = `${cyanFull}0.3)`;
ctx.lineWidth = 0.5;
[268, 282, 296, 310].forEach(wx => {
ctx.beginPath();
ctx.roundRect(wx, 30, 10, 6, 1);
ctx.stroke();
});
}
// 마스트
ctx.strokeStyle = `${cyanFull}0.4)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(295, 25);
ctx.lineTo(295, 8);
ctx.stroke();
ctx.strokeStyle = `${cyanFull}0.3)`;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(288, 12);
ctx.lineTo(302, 12);
ctx.stroke();
// 연통 (funnel)
ctx.beginPath();
ctx.roundRect(235, 38, 18, 22, 1);
ctx.fillStyle = funnelFill;
ctx.fill();
ctx.strokeStyle = funnelStroke;
ctx.lineWidth = isWire ? 0.8 : 1;
ctx.stroke();
// 화물 크레인
ctx.strokeStyle = craneStroke;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(150, 60); ctx.lineTo(150, 20);
ctx.moveTo(150, 22); ctx.lineTo(120, 40);
ctx.moveTo(180, 60); ctx.lineTo(180, 25);
ctx.moveTo(180, 27); ctx.lineTo(155, 42);
ctx.stroke();
// 포인트 클라우드
if (isPoint) {
const rand = mulberry32(42);
for (let i = 0; i < 5000; i++) {
const x = 35 + rand() * 355;
const y = 15 + rand() * 160;
const inHull = y > 60 && y < 175 && x > 35 && x < 390;
const inBridge = x > 260 && x < 330 && y > 25 && y < 60;
if (!inHull && !inBridge && rand() > 0.15) continue;
const alpha = 0.15 + rand() * 0.55;
const r = 0.4 + rand() * 0.6;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = `${cyanFull}${alpha})`;
ctx.fill();
// 에지 (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([]);
}
}
// 선수/선미 표시
ctx.fillStyle = `${cyanFull}0.3)`;
ctx.font = '8px var(--fM, monospace)';
ctx.fillText('선수', 395, 95);
ctx.fillText('선미', 15, 95);
// 포인트 (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);
// 측정선 (3D 모드)
if (viewMode === '3d') {
ctx.strokeStyle = `${greenFull}0.4)`;
ctx.lineWidth = 0.5;
ctx.setLineDash([3, 2]);
ctx.beginPath();
ctx.moveTo(30, 185);
ctx.lineTo(395, 185);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = `${greenFull}0.6)`;
ctx.font = '8px var(--fM, monospace)';
ctx.textAlign = 'center';
ctx.fillText('84.7m', 200, 195);
ctx.textAlign = 'left';
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();
});
}
ctx.strokeStyle = `${orangeFull}0.4)`;
ctx.lineWidth = 0.5;
ctx.setLineDash([3, 2]);
ctx.beginPath();
ctx.moveTo(405, 60);
ctx.lineTo(405, 175);
ctx.stroke();
ctx.setLineDash([]);
// 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.save();
ctx.fillStyle = `${orangeFull}0.6)`;
ctx.font = '8px var(--fM, monospace)';
ctx.translate(415, 120);
ctx.rotate(Math.PI / 2);
ctx.fillText('14.2m', 0, 0);
ctx.restore();
}
}, [viewMode, isProcessing, isWire, isPoint]);
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" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(15deg) rotateY(-25deg) rotateZ(2deg)', transformStyle: 'preserve-3d', position: 'relative', width: `${W}px`, height: `${H}px` }}>
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse"> ...</div>
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
</div>
<div 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>
)}
</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 isWire = viewMode === 'wire';
const isPoint = viewMode === 'point';
const canvasRef = useRef<HTMLCanvasElement>(null);
const angleRef = useRef(0);
const rafRef = useRef<number>(0);
const W = 380;
const H = 260;
const W = 420;
const H = 300;
const FOV = 4.0;
const geo = useMemo(() => buildPollutionGeometry(), []);
useEffect(() => {
const canvas = canvasRef.current;
@ -288,213 +455,133 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const cyanFull = 'rgba(6,182,212,';
const orangeFull = 'rgba(249,115,22,';
const redFull = 'rgba(239,68,68,';
const greenFull = 'rgba(34,197,94,';
const blueFull = 'rgba(59,130,246,';
const yellowFull = 'rgba(234,179,8,';
const cx = W / 2;
const cy = H / 2 + 20;
const scale3d = 100;
// 해수면 그리드
ctx.strokeStyle = `${cyanFull}0.08)`;
ctx.lineWidth = 0.4;
for (let i = 0; i < 15; i++) {
ctx.beginPath();
ctx.moveTo(0, i * 20);
ctx.lineTo(380, i * 20);
ctx.stroke();
}
for (let i = 0; i < 20; i++) {
ctx.beginPath();
ctx.moveTo(i * 20, 0);
ctx.lineTo(i * 20, 260);
ctx.stroke();
}
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 };
};
// 와이어프레임 / 포인트 모드 등고선 타원
if (isWire || isPoint) {
ctx.strokeStyle = `${redFull}0.12)`;
ctx.lineWidth = 0.3;
ctx.setLineDash([]);
[[140, 80], [100, 55], [60, 35]].forEach(([rx, ry]) => {
ctx.beginPath();
ctx.ellipse(190, 145, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
});
}
const render = () => {
ctx.clearRect(0, 0, W, H);
const angle = angleRef.current;
const alphaBase = isProcessing ? 0.25 : 1.0;
// 유막 메인 형태 (blob)
ctx.beginPath();
ctx.moveTo(120, 80);
ctx.quadraticCurveTo(80, 90, 70, 120);
ctx.quadraticCurveTo(55, 155, 80, 180);
ctx.quadraticCurveTo(100, 205, 140, 210);
ctx.quadraticCurveTo(180, 220, 220, 205);
ctx.quadraticCurveTo(270, 195, 300, 170);
ctx.quadraticCurveTo(320, 145, 310, 115);
ctx.quadraticCurveTo(300, 85, 270, 75);
ctx.quadraticCurveTo(240, 65, 200, 70);
ctx.quadraticCurveTo(160, 68, 120, 80);
ctx.closePath();
ctx.fillStyle = isWire || isPoint ? 'transparent' : `${redFull}0.08)`;
ctx.fill();
ctx.strokeStyle = isProcessing ? `${redFull}0.15)` : `${redFull}0.45)`;
ctx.lineWidth = isWire ? 0.8 : 1.5;
ctx.setLineDash([]);
ctx.stroke();
const showPoints = viewMode === 'point' || viewMode === '3d';
const showWire = viewMode === 'wire' || viewMode === '3d';
// 유막 두께 등고선
ctx.beginPath();
ctx.moveTo(155, 100);
ctx.quadraticCurveTo(125, 115, 120, 140);
ctx.quadraticCurveTo(115, 165, 135, 180);
ctx.quadraticCurveTo(155, 195, 190, 190);
ctx.quadraticCurveTo(230, 185, 255, 165);
ctx.quadraticCurveTo(270, 145, 260, 120);
ctx.quadraticCurveTo(250, 100, 225, 95);
ctx.quadraticCurveTo(195, 88, 155, 100);
ctx.closePath();
ctx.fillStyle = isWire || isPoint ? 'transparent' : `${orangeFull}0.08)`;
ctx.fill();
ctx.strokeStyle = isProcessing ? `${orangeFull}0.12)` : `${orangeFull}0.35)`;
ctx.lineWidth = 0.8;
if (isWire) ctx.setLineDash([4, 2]); else ctx.setLineDash([]);
ctx.stroke();
ctx.setLineDash([]);
// 에지 렌더링
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();
ctx.moveTo(175, 120);
ctx.quadraticCurveTo(160, 130, 165, 150);
ctx.quadraticCurveTo(170, 170, 195, 170);
ctx.quadraticCurveTo(220, 168, 230, 150);
ctx.quadraticCurveTo(235, 130, 220, 120);
ctx.quadraticCurveTo(205, 110, 175, 120);
ctx.closePath();
ctx.fillStyle = isWire || isPoint ? 'transparent' : `${redFull}0.15)`;
ctx.fill();
ctx.strokeStyle = isProcessing ? `${redFull}0.15)` : `${redFull}0.5)`;
ctx.lineWidth = 0.8;
ctx.stroke();
// 확산 방향 화살표
ctx.strokeStyle = `${orangeFull}0.5)`;
ctx.fillStyle = `${orangeFull}0.5)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(250, 140);
ctx.lineTo(330, 120);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(330, 120);
ctx.lineTo(322, 115);
ctx.lineTo(324, 123);
ctx.closePath();
ctx.fill();
ctx.fillStyle = `${orangeFull}0.6)`;
ctx.font = '8px var(--fM, monospace)';
ctx.fillText('ESE 0.3km/h', 335, 122);
// 포인트 클라우드
if (isPoint) {
const rand = mulberry32(99);
const cx = 190, cy = 145, rx = 130, ry = 75;
for (let i = 0; i < 8000; i++) {
const angle = rand() * Math.PI * 2;
const r = Math.sqrt(rand());
const x = cx + r * rx * Math.cos(angle);
const y = cy + r * ry * Math.sin(angle);
if (x < 40 || x > 340 || y < 50 || y > 230) continue;
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2);
const intensity = Math.max(0.1, 1 - dist);
let color: string;
if (dist < 0.4) color = `${redFull}${intensity * 0.7})`;
else if (dist < 0.7) color = `${orangeFull}${intensity * 0.5})`;
else color = `${yellowFull}${intensity * 0.3})`;
const pr = 0.3 + rand() * 0.7;
ctx.beginPath();
ctx.arc(x, y, pr, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
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([]);
}
}
// 두께 색상 범례 텍스트 (3D 모드)
if (viewMode === '3d') {
ctx.textAlign = 'center';
ctx.font = '7px var(--fM, monospace)';
ctx.fillStyle = `${redFull}0.7)`;
ctx.fillText('3.2mm', 165, 148);
ctx.fillStyle = `${orangeFull}0.5)`;
ctx.fillText('1.5mm', 130, 165);
ctx.fillStyle = `${yellowFull}0.4)`;
ctx.fillText('0.3mm', 95, 130);
ctx.textAlign = 'left';
// 포인트 (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);
// 측정선
ctx.strokeStyle = `${greenFull}0.4)`;
ctx.lineWidth = 0.5;
ctx.setLineDash([3, 2]);
ctx.beginPath();
ctx.moveTo(55, 240);
ctx.lineTo(320, 240);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = `${greenFull}0.6)`;
ctx.font = '8px var(--fM, monospace)';
ctx.textAlign = 'center';
ctx.fillText('1.24 km', 187, 252);
ctx.textAlign = 'left';
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();
});
}
ctx.strokeStyle = `${blueFull}0.4)`;
ctx.lineWidth = 0.5;
ctx.setLineDash([3, 2]);
ctx.beginPath();
ctx.moveTo(25, 80);
ctx.lineTo(25, 210);
ctx.stroke();
ctx.setLineDash([]);
// 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.save();
ctx.fillStyle = `${blueFull}0.6)`;
ctx.font = '8px var(--fM, monospace)';
ctx.translate(15, 150);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.fillText('0.68 km', 0, 0);
ctx.restore();
}
}, [viewMode, isProcessing, isWire, isPoint]);
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" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: `${W}px`, height: `${H}px` }}>
<canvas
ref={canvasRef}
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
/>
<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-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>
)}
{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>
{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>
);
}

파일 보기

@ -160,6 +160,19 @@ export function OilSpillView() {
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
// 분석 탭 초기 진입 시 기본 데모 자동 표시
useEffect(() => {
if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) {
const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set<PredictionModel>(['KOSPS']))
const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSubTab])
const handleToggleLayer = (layerId: string, enabled: boolean) => {
setEnabledLayers(prev => {
const newSet = new Set(prev)
@ -322,6 +335,17 @@ export function OilSpillView() {
}
// 분석 화면으로 전환
setActiveSubTab('analysis')
// 데모 궤적 자동 생성 (화면 진입 즉시 시각화)
const coord = (analysis.lon != null && analysis.lat != null)
? { lon: analysis.lon, lat: analysis.lat }
: incidentCoord
const demoModels = Array.from(models.size > 0 ? models : new Set<PredictionModel>(['KOSPS']))
const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48)
setOilTrajectory(demoTrajectory)
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
}
const handleMapClick = (lon: number, lat: number) => {