Merge pull request 'develop' (#54) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 30s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 30s
Reviewed-on: #54
This commit is contained in:
커밋
5bf2f6688b
@ -481,13 +481,13 @@ export function MapView({
|
|||||||
getSize: 12,
|
getSize: 12,
|
||||||
getColor: [255, 255, 255, 200],
|
getColor: [255, 255, 255, 200],
|
||||||
getPixelOffset: [0, -20],
|
getPixelOffset: [0, -20],
|
||||||
fontFamily: 'var(--fK), sans-serif',
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||||
fontSettings: { sdf: false },
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 200],
|
||||||
billboard: true,
|
billboard: true,
|
||||||
sizeUnits: 'pixels' as const,
|
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,
|
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing,
|
||||||
getSize: 14,
|
getSize: 14,
|
||||||
getColor: [6, 182, 212, 70],
|
getColor: [6, 182, 212, 70],
|
||||||
|
characterSet: 'auto',
|
||||||
sizeUnits: 'pixels' as const,
|
sizeUnits: 'pixels' as const,
|
||||||
billboard: true,
|
billboard: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -176,8 +176,11 @@ export function RealtimeDrone() {
|
|||||||
getText: d => `${d.id}구역`,
|
getText: d => `${d.id}구역`,
|
||||||
getColor: d => [...d.color, 180],
|
getColor: d => [...d.color, 180],
|
||||||
getSize: 12,
|
getSize: 12,
|
||||||
fontFamily: 'Noto Sans KR, sans-serif',
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||||
fontWeight: 700,
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 180],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
billboard: false,
|
billboard: false,
|
||||||
@ -209,8 +212,11 @@ export function RealtimeDrone() {
|
|||||||
getText: () => '유류확산',
|
getText: () => '유류확산',
|
||||||
getColor: [249, 115, 22, 200],
|
getColor: [249, 115, 22, 200],
|
||||||
getSize: 11,
|
getSize: 11,
|
||||||
fontFamily: 'Noto Sans KR, sans-serif',
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||||
fontWeight: 700,
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 180],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
billboard: false,
|
billboard: false,
|
||||||
@ -248,8 +254,11 @@ export function RealtimeDrone() {
|
|||||||
getText: () => 'HNS 의심',
|
getText: () => 'HNS 의심',
|
||||||
getColor: [234, 179, 8, 180],
|
getColor: [234, 179, 8, 180],
|
||||||
getSize: 10,
|
getSize: 10,
|
||||||
fontFamily: 'Noto Sans KR, sans-serif',
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||||
fontWeight: 700,
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 180],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
billboard: false,
|
billboard: false,
|
||||||
@ -291,8 +300,11 @@ export function RealtimeDrone() {
|
|||||||
getText: d => d.name,
|
getText: d => d.name,
|
||||||
getColor: [255, 255, 255, 190],
|
getColor: [255, 255, 255, 190],
|
||||||
getSize: 11,
|
getSize: 11,
|
||||||
fontFamily: 'Noto Sans KR, sans-serif',
|
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
|
||||||
fontWeight: 700,
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 180],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
billboard: false,
|
billboard: false,
|
||||||
@ -398,7 +410,10 @@ export function RealtimeDrone() {
|
|||||||
},
|
},
|
||||||
getSize: d => selectedDrone === d.id ? 13 : 10,
|
getSize: d => selectedDrone === d.id ? 13 : 10,
|
||||||
fontFamily: 'Outfit, monospace',
|
fontFamily: 'Outfit, monospace',
|
||||||
fontWeight: 700,
|
fontWeight: 'bold',
|
||||||
|
characterSet: 'auto',
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineColor: [15, 21, 36, 200],
|
||||||
getTextAnchor: 'middle',
|
getTextAnchor: 'middle',
|
||||||
getAlignmentBaseline: 'center',
|
getAlignmentBaseline: 'center',
|
||||||
billboard: false,
|
billboard: false,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState, useMemo } from 'react';
|
||||||
|
|
||||||
interface ReconItem {
|
interface ReconItem {
|
||||||
id: string
|
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 }) {
|
function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||||
const isProcessing = status === 'processing';
|
const isProcessing = status === 'processing';
|
||||||
const isWire = viewMode === 'wire';
|
|
||||||
const isPoint = viewMode === 'point';
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const angleRef = useRef(0);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
const W = 420;
|
const W = 480;
|
||||||
const H = 200;
|
const H = 280;
|
||||||
|
const FOV = 3.5;
|
||||||
|
const geo = useMemo(() => buildVesselGeometry(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@ -48,211 +206,102 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.scale(dpr, dpr);
|
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);
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
const angle = angleRef.current;
|
||||||
|
const alphaBase = isProcessing ? 0.25 : 1.0;
|
||||||
|
|
||||||
const cyanFull = 'rgba(6,182,212,';
|
const showPoints = viewMode === 'point' || viewMode === '3d';
|
||||||
const orangeFull = 'rgba(249,115,22,';
|
const showWire = viewMode === 'wire' || viewMode === '3d';
|
||||||
const redFull = 'rgba(239,68,68,';
|
|
||||||
const greenFull = 'rgba(34,197,94,';
|
|
||||||
|
|
||||||
const hullStroke = isProcessing ? `${cyanFull}0.2)` : `${cyanFull}0.5)`;
|
// 에지 (wireframe)
|
||||||
const hullFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.08)`;
|
if (showWire) {
|
||||||
const deckStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.45)`;
|
geo.edges.forEach(edge => {
|
||||||
const deckFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.05)`;
|
const ptA = geo.points[edge.a];
|
||||||
const bridgeStroke = isProcessing ? `${cyanFull}0.15)` : `${cyanFull}0.5)`;
|
const ptB = geo.points[edge.b];
|
||||||
const bridgeFill = isWire || isPoint ? 'transparent' : `${cyanFull}0.1)`;
|
if (!ptA || !ptB) return;
|
||||||
const funnelStroke = isProcessing ? `${redFull}0.15)` : `${redFull}0.4)`;
|
const projA = project(ptA.x, ptA.y, ptA.z, angle);
|
||||||
const funnelFill = isWire || isPoint ? 'transparent' : `${redFull}0.1)`;
|
const projB = project(ptB.x, ptB.y, ptB.z, angle);
|
||||||
const craneStroke = isProcessing ? `${orangeFull}0.15)` : `${orangeFull}0.4)`;
|
const avgSc = (projA.sc + projB.sc) / 2;
|
||||||
|
const brightness = 0.3 + avgSc * 0.5;
|
||||||
// 수선 (waterline)
|
const lineAlpha = (edge.isWaterline ? 0.25 : viewMode === 'wire' ? 0.6 : 0.3) * alphaBase * brightness;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.ellipse(210, 165, 200, 12, 0, 0, Math.PI * 2);
|
ctx.moveTo(projA.sx, projA.sy);
|
||||||
ctx.strokeStyle = `${cyanFull}0.15)`;
|
ctx.lineTo(projB.sx, projB.sy);
|
||||||
ctx.lineWidth = 0.5;
|
ctx.setLineDash(edge.isWaterline ? [3, 2] : []);
|
||||||
ctx.setLineDash([4, 2]);
|
ctx.strokeStyle = `rgba(${edge.r},${edge.g},${edge.bCh},${lineAlpha})`;
|
||||||
|
ctx.lineWidth = viewMode === 'wire' ? 0.8 : 0.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
});
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
// 선체 (hull)
|
// 포인트 (back-to-front 정렬)
|
||||||
ctx.beginPath();
|
if (showPoints) {
|
||||||
ctx.moveTo(30, 140);
|
const projected = geo.points.map(pt => {
|
||||||
ctx.quadraticCurveTo(40, 170, 100, 175);
|
const { sx, sy, sc } = project(pt.x, pt.y, pt.z, angle);
|
||||||
ctx.lineTo(320, 175);
|
return { sx, sy, sc, pt };
|
||||||
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();
|
|
||||||
|
|
||||||
// 선체 하부
|
|
||||||
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 => {
|
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.beginPath();
|
||||||
ctx.moveTo(30, y);
|
ctx.arc(sx, sy, Math.max(0.3, ptRadius), 0, Math.PI * 2);
|
||||||
ctx.lineTo(395, y);
|
ctx.fillStyle = `rgba(${pt.r},${pt.g},${pt.b},${ptAlpha})`;
|
||||||
ctx.stroke();
|
ctx.fill();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선교 (bridge)
|
// Labels (3d 모드, !isProcessing)
|
||||||
ctx.beginPath();
|
if (viewMode === '3d' && !isProcessing) {
|
||||||
ctx.roundRect(260, 25, 70, 35, 2);
|
const bowProj = project(1.0, 0, 0, angle);
|
||||||
ctx.fillStyle = bridgeFill;
|
const sternProj = project(-1.0, 0, 0, angle);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선수/선미 표시
|
|
||||||
ctx.fillStyle = `${cyanFull}0.3)`;
|
|
||||||
ctx.font = '8px var(--fM, monospace)';
|
ctx.font = '8px var(--fM, monospace)';
|
||||||
ctx.fillText('선수', 395, 95);
|
ctx.fillStyle = 'rgba(6,182,212,0.5)';
|
||||||
ctx.fillText('선미', 15, 95);
|
ctx.fillText('선수', bowProj.sx + 4, bowProj.sy);
|
||||||
|
ctx.fillText('선미', sternProj.sx - 20, sternProj.sy);
|
||||||
|
|
||||||
// 측정선 (3D 모드)
|
ctx.fillStyle = 'rgba(34,197,94,0.7)';
|
||||||
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.textAlign = 'center';
|
||||||
ctx.fillText('84.7m', 200, 195);
|
ctx.fillText('84.7m', cx, H - 12);
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
|
|
||||||
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([]);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
angleRef.current += 0.006;
|
||||||
|
rafRef.current = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [viewMode, isProcessing, geo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<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 }} />
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@ -264,18 +313,136 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
|||||||
</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 }) {
|
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||||
const isProcessing = status === 'processing';
|
const isProcessing = status === 'processing';
|
||||||
const isWire = viewMode === 'wire';
|
|
||||||
const isPoint = viewMode === 'point';
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const angleRef = useRef(0);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
const W = 380;
|
const W = 420;
|
||||||
const H = 260;
|
const H = 300;
|
||||||
|
const FOV = 4.0;
|
||||||
|
const geo = useMemo(() => buildPollutionGeometry(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@ -288,196 +455,117 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
ctx.scale(dpr, dpr);
|
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);
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
const angle = angleRef.current;
|
||||||
|
const alphaBase = isProcessing ? 0.25 : 1.0;
|
||||||
|
|
||||||
const cyanFull = 'rgba(6,182,212,';
|
const showPoints = viewMode === 'point' || viewMode === '3d';
|
||||||
const orangeFull = 'rgba(249,115,22,';
|
const showWire = viewMode === 'wire' || viewMode === '3d';
|
||||||
const redFull = 'rgba(239,68,68,';
|
|
||||||
const greenFull = 'rgba(34,197,94,';
|
// 에지 렌더링
|
||||||
const blueFull = 'rgba(59,130,246,';
|
if (showWire) {
|
||||||
const yellowFull = 'rgba(234,179,8,';
|
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.strokeStyle = `${cyanFull}0.08)`;
|
|
||||||
ctx.lineWidth = 0.4;
|
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, i * 20);
|
const first = project(edge.points[0].x, edge.points[0].y, edge.points[0].z, angle);
|
||||||
ctx.lineTo(380, i * 20);
|
ctx.moveTo(first.sx, first.sy);
|
||||||
ctx.stroke();
|
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);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < 20; i++) {
|
ctx.setLineDash(edge.isDash ? [3, 2] : []);
|
||||||
ctx.beginPath();
|
ctx.strokeStyle = `rgba(${edge.r},${edge.g},${edge.b},${lineAlpha})`;
|
||||||
ctx.moveTo(i * 20, 0);
|
ctx.lineWidth = isGrid ? 0.4 : 0.8;
|
||||||
ctx.lineTo(i * 20, 260);
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
});
|
||||||
|
|
||||||
// 와이어프레임 / 포인트 모드 등고선 타원
|
|
||||||
if (isWire || isPoint) {
|
|
||||||
ctx.strokeStyle = `${redFull}0.12)`;
|
|
||||||
ctx.lineWidth = 0.3;
|
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
[[140, 80], [100, 55], [60, 35]].forEach(([rx, ry]) => {
|
}
|
||||||
|
|
||||||
|
// 포인트 (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.beginPath();
|
||||||
ctx.ellipse(190, 145, rx, ry, 0, 0, Math.PI * 2);
|
ctx.arc(sx, sy, Math.max(0.3, ptRadius), 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.fillStyle = `rgba(${pt.r},${pt.g},${pt.b},${ptAlpha})`;
|
||||||
|
ctx.fill();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유막 메인 형태 (blob)
|
// Labels (3d 모드, !isProcessing)
|
||||||
ctx.beginPath();
|
if (viewMode === '3d' && !isProcessing) {
|
||||||
ctx.moveTo(120, 80);
|
const p30 = project(0, 0.08, 0, angle);
|
||||||
ctx.quadraticCurveTo(80, 90, 70, 120);
|
const p60 = project(0, 0.04, 0.45, angle);
|
||||||
ctx.quadraticCurveTo(55, 155, 80, 180);
|
const p90 = project(0, 0, 0.8, angle);
|
||||||
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();
|
|
||||||
|
|
||||||
// 유막 두께 등고선
|
|
||||||
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([]);
|
|
||||||
|
|
||||||
// 유막 최고 두께 핵심
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 두께 색상 범례 텍스트 (3D 모드)
|
|
||||||
if (viewMode === '3d') {
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.font = '7px var(--fM, monospace)';
|
ctx.font = '7px var(--fM, monospace)';
|
||||||
ctx.fillStyle = `${redFull}0.7)`;
|
ctx.fillStyle = 'rgba(239,68,68,0.75)';
|
||||||
ctx.fillText('3.2mm', 165, 148);
|
ctx.fillText('3.2mm', p30.sx + 3, p30.sy);
|
||||||
ctx.fillStyle = `${orangeFull}0.5)`;
|
ctx.fillStyle = 'rgba(249,115,22,0.65)';
|
||||||
ctx.fillText('1.5mm', 130, 165);
|
ctx.fillText('1.5mm', p60.sx + 3, p60.sy);
|
||||||
ctx.fillStyle = `${yellowFull}0.4)`;
|
ctx.fillStyle = 'rgba(234,179,8,0.6)';
|
||||||
ctx.fillText('0.3mm', 95, 130);
|
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.textAlign = 'left';
|
||||||
|
|
||||||
// 측정선
|
ctx.fillStyle = 'rgba(249,115,22,0.6)';
|
||||||
ctx.strokeStyle = `${greenFull}0.4)`;
|
ctx.fillText('ESE 0.3km/h', cx + 55, cy - 30);
|
||||||
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';
|
|
||||||
|
|
||||||
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([]);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
angleRef.current += 0.006;
|
||||||
|
rafRef.current = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [viewMode, isProcessing, geo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<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 }} />
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{viewMode === '3d' && !isProcessing && (
|
{viewMode === '3d' && !isProcessing && (
|
||||||
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
<div className="absolute bottom-2 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||||
<span>0mm</span>
|
<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))' }} />
|
<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>
|
<span>3.2mm</span>
|
||||||
@ -495,7 +583,6 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -160,6 +160,19 @@ export function OilSpillView() {
|
|||||||
// 재계산 상태
|
// 재계산 상태
|
||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
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) => {
|
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||||
setEnabledLayers(prev => {
|
setEnabledLayers(prev => {
|
||||||
const newSet = new Set(prev)
|
const newSet = new Set(prev)
|
||||||
@ -322,6 +335,17 @@ export function OilSpillView() {
|
|||||||
}
|
}
|
||||||
// 분석 화면으로 전환
|
// 분석 화면으로 전환
|
||||||
setActiveSubTab('analysis')
|
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) => {
|
const handleMapClick = (lon: number, lat: number) => {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user