diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 040407f..03914e8 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -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, }) diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index adb6892..6b56d84 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -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, diff --git a/frontend/src/tabs/aerial/components/SensorAnalysis.tsx b/frontend/src/tabs/aerial/components/SensorAnalysis.tsx index 4039bc2..f051179 100644 --- a/frontend/src/tabs/aerial/components/SensorAnalysis.tsx +++ b/frontend/src/tabs/aerial/components/SensorAnalysis.tsx @@ -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(null); + const angleRef = useRef(0); + const rafRef = useRef(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 ( -
-
- - - {isProcessing && ( -
-
-
재구성 처리중...
-
-
-
+
+ + {isProcessing && ( +
+
+
재구성 처리중...
+
+
- )} -
+
+ )}
); } +// ───────────────────────────────────────────── +// 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(null); + const angleRef = useRef(0); + const rafRef = useRef(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 ( -
-
- +
+ - {viewMode === '3d' && !isProcessing && ( -
- 0mm -
- 3.2mm -
- )} + {viewMode === '3d' && !isProcessing && ( +
+ 0mm +
+ 3.2mm +
+ )} - {isProcessing && ( -
-
-
재구성 처리중...
-
-
-
+ {isProcessing && ( +
+
+
재구성 처리중...
+
+
- )} -
+
+ )}
); } diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 50dd0df..d531d47 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -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(['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(['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) => {