@ -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 > 0 mm < / 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.2 mm < / 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 > 0 mm < / 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.2 mm < / 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 >
) ;
}