develop #52
@ -1,14 +1,14 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
||||
import type { PickingInfo } from '@deck.gl/core'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { layerDatabase } from '@common/services/layerService'
|
||||
import { decimalToDMS } from '@common/utils/coordinates'
|
||||
import type { PredictionModel } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||||
@ -70,6 +70,19 @@ const PRIORITY_LABELS: Record<string, string> = {
|
||||
'MEDIUM': '보통',
|
||||
}
|
||||
|
||||
const SENSITIVE_COLORS: Record<string, string> = {
|
||||
'aquaculture': '#22c55e',
|
||||
'beach': '#0ea5e9',
|
||||
'ecology': '#eab308',
|
||||
'intake': '#a855f7',
|
||||
}
|
||||
const SENSITIVE_ICONS: Record<string, string> = {
|
||||
'aquaculture': '🐟',
|
||||
'beach': '🏖',
|
||||
'ecology': '🦅',
|
||||
'intake': '🚰',
|
||||
}
|
||||
|
||||
interface DispersionZone {
|
||||
level: string
|
||||
color: string
|
||||
@ -108,6 +121,7 @@ interface MapViewProps {
|
||||
totalFrames: number
|
||||
incidentCoord: { lat: number; lon: number }
|
||||
}
|
||||
sensitiveResources?: SensitiveResource[]
|
||||
}
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||
@ -141,6 +155,7 @@ export function MapView({
|
||||
layerOpacity = 50,
|
||||
layerBrightness = 50,
|
||||
backtrackReplay,
|
||||
sensitiveResources = [],
|
||||
}: MapViewProps) {
|
||||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
@ -402,11 +417,121 @@ export function MapView({
|
||||
}))
|
||||
}
|
||||
|
||||
// --- 민감자원 영역 (ScatterplotLayer) ---
|
||||
if (sensitiveResources.length > 0) {
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'sensitive-zones',
|
||||
data: sensitiveResources,
|
||||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||||
getRadius: (d: SensitiveResource) => d.radiusM,
|
||||
getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40),
|
||||
getLineColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150),
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters' as const,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo) => {
|
||||
if (info.object) {
|
||||
const d = info.object as SensitiveResource
|
||||
setPopupInfo({
|
||||
longitude: d.lon,
|
||||
latitude: d.lat,
|
||||
content: (
|
||||
<div className="text-xs" style={{ fontFamily: 'var(--fK)', minWidth: '130px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' }}>
|
||||
<span>{SENSITIVE_ICONS[d.type]}</span>
|
||||
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#666' }}>
|
||||
반경: {d.radiusM}m<br />
|
||||
도달 예상: <strong style={{ color: d.arrivalTimeH <= 6 ? '#ef4444' : '#f97316' }}>{d.arrivalTimeH}h</strong>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// 민감자원 중심 마커
|
||||
result.push(
|
||||
new ScatterplotLayer({
|
||||
id: 'sensitive-centers',
|
||||
data: sensitiveResources,
|
||||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||||
getRadius: 6,
|
||||
getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220),
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getLineWidth: 2,
|
||||
stroked: true,
|
||||
radiusMinPixels: 6,
|
||||
radiusMaxPixels: 10,
|
||||
})
|
||||
)
|
||||
|
||||
// 민감자원 라벨
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: 'sensitive-labels',
|
||||
data: sensitiveResources,
|
||||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||||
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
|
||||
getSize: 12,
|
||||
getColor: [255, 255, 255, 200],
|
||||
getPixelOffset: [0, -20],
|
||||
fontFamily: 'var(--fK), sans-serif',
|
||||
fontSettings: { sdf: false },
|
||||
billboard: true,
|
||||
sizeUnits: 'pixels' as const,
|
||||
background: true,
|
||||
getBackgroundColor: [15, 21, 36, 180],
|
||||
backgroundPadding: [4, 2],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- 해류 화살표 (TextLayer) ---
|
||||
if (incidentCoord) {
|
||||
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
|
||||
const gridSize = 5
|
||||
const spacing = 0.04 // 약 4km 간격
|
||||
const mainBearing = 42 // NE 방향 (도)
|
||||
|
||||
for (let row = -gridSize; row <= gridSize; row++) {
|
||||
for (let col = -gridSize; col <= gridSize; col++) {
|
||||
const lat = incidentCoord.lat + row * spacing
|
||||
const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180)
|
||||
// 사고 지점에서 멀어질수록 해류 방향 약간 변화
|
||||
const distFactor = Math.sqrt(row * row + col * col) / gridSize
|
||||
const localBearing = mainBearing + (col * 3) + (row * 2)
|
||||
const speed = 0.3 + (1 - distFactor) * 0.2
|
||||
currentArrows.push({ lon, lat, bearing: localBearing, speed })
|
||||
}
|
||||
}
|
||||
|
||||
result.push(
|
||||
new TextLayer({
|
||||
id: 'current-arrows',
|
||||
data: currentArrows,
|
||||
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
|
||||
getText: () => '→',
|
||||
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing,
|
||||
getSize: 14,
|
||||
getColor: [6, 182, 212, 70],
|
||||
sizeUnits: 'pixels' as const,
|
||||
billboard: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [
|
||||
oilTrajectory, currentTime, selectedModels,
|
||||
boomLines, isDrawingBoom, drawingPoints,
|
||||
dispersionResult, incidentCoord, backtrackReplay,
|
||||
sensitiveResources,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { AerialTheoryView } from './AerialTheoryView'
|
||||
import { MediaManagement } from './MediaManagement'
|
||||
@ -8,46 +7,33 @@ import { SensorAnalysis } from './SensorAnalysis'
|
||||
import { SatelliteRequest } from './SatelliteRequest'
|
||||
import { CctvView } from './CctvView'
|
||||
|
||||
type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor'
|
||||
|
||||
export function AerialView() {
|
||||
const { activeSubTab } = useSubMenu('aerial')
|
||||
const [activeTab, setActiveTab] = useState<AerialTab>('media')
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'media' || activeSubTab === 'analysis' || activeSubTab === 'realtime' || activeSubTab === 'sensor') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveTab(activeSubTab as AerialTab)
|
||||
const renderContent = () => {
|
||||
switch (activeSubTab) {
|
||||
case 'theory':
|
||||
return <AerialTheoryView />
|
||||
case 'satellite':
|
||||
return <SatelliteRequest />
|
||||
case 'cctv':
|
||||
return <CctvView />
|
||||
case 'analysis':
|
||||
return <OilAreaAnalysis />
|
||||
case 'realtime':
|
||||
return <RealtimeDrone />
|
||||
case 'sensor':
|
||||
return <SensorAnalysis />
|
||||
case 'media':
|
||||
default:
|
||||
return <MediaManagement />
|
||||
}
|
||||
}, [activeSubTab])
|
||||
|
||||
if (activeSubTab === 'theory') {
|
||||
return <AerialTheoryView />
|
||||
}
|
||||
if (activeSubTab === 'satellite') {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-auto"><SatelliteRequest /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (activeSubTab === 'cctv') {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-hidden px-6 py-5"><CctvView /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-bg-0">
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
<div className="w-full h-full">
|
||||
{activeTab === 'media' && <MediaManagement />}
|
||||
{activeTab === 'analysis' && <OilAreaAnalysis />}
|
||||
{activeTab === 'realtime' && <RealtimeDrone />}
|
||||
{activeTab === 'sensor' && <SensorAnalysis />}
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -248,17 +248,17 @@ export function CctvView() {
|
||||
<div className="text-[10px] text-text-3 font-korean opacity-50">지도 영역</div>
|
||||
{/* 간략 지도 표현 */}
|
||||
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
|
||||
{cctvCameras.filter(c => c.status === 'live').slice(0, 6).map((c, i) => (
|
||||
{cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 rounded-full cursor-pointer"
|
||||
style={{
|
||||
background: selectedCamera?.id === c.id ? 'var(--cyan)' : 'var(--green)',
|
||||
boxShadow: selectedCamera?.id === c.id ? '0 0 6px var(--cyan)' : 'none',
|
||||
background: selectedCamera?.cctvSn === c.cctvSn ? 'var(--cyan)' : 'var(--green)',
|
||||
boxShadow: selectedCamera?.cctvSn === c.cctvSn ? '0 0 6px var(--cyan)' : 'none',
|
||||
top: `${20 + (i * 25) % 70}%`,
|
||||
left: `${15 + (i * 30) % 70}%`,
|
||||
}}
|
||||
title={c.name}
|
||||
title={c.cameraNm}
|
||||
onClick={() => handleSelectCamera(c)}
|
||||
/>
|
||||
))}
|
||||
@ -298,7 +298,7 @@ export function CctvView() {
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 rounded-[5px] cursor-pointer hover:bg-bg-hover transition-colors"
|
||||
onClick={() => {
|
||||
const found = cctvCameras.find(c => c.name === fav.name)
|
||||
const found = cameras.find(c => c.cameraNm === fav.name)
|
||||
if (found) handleSelectCamera(found)
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,5 +1,37 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Map, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
||||
import type { StyleSpecification } from 'maplibre-gl'
|
||||
import type { PickingInfo } from '@deck.gl/core'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
// ── 지도 스타일 ─────────────────────────────────────────
|
||||
const BASE_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
'carto-dark': {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||||
],
|
||||
tileSize: 256,
|
||||
attribution: '© OSM © CARTO',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }],
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||
overlay.setProps({ layers })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Mock 데이터 ─────────────────────────────────────────
|
||||
interface DroneInfo {
|
||||
id: string
|
||||
name: string
|
||||
@ -9,17 +41,51 @@ interface DroneInfo {
|
||||
speed: number
|
||||
sensor: string
|
||||
color: string
|
||||
lon: number
|
||||
lat: number
|
||||
}
|
||||
|
||||
const drones: DroneInfo[] = [
|
||||
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: 'var(--blue)' },
|
||||
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: 'var(--red)' },
|
||||
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: 'var(--purple)' },
|
||||
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: 'var(--green)' },
|
||||
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: 'var(--orange)' },
|
||||
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: 'var(--t3)' },
|
||||
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: '#3b82f6', lon: 128.68, lat: 34.72 },
|
||||
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: '#ef4444', lon: 128.74, lat: 34.68 },
|
||||
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: '#a855f7', lon: 128.88, lat: 34.60 },
|
||||
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: '#22c55e', lon: 128.62, lat: 34.56 },
|
||||
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: '#f97316', lon: 128.80, lat: 34.75 },
|
||||
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: '#6b7280', lon: 128.70, lat: 34.65 },
|
||||
]
|
||||
|
||||
interface VesselInfo {
|
||||
name: string
|
||||
lon: number
|
||||
lat: number
|
||||
aisOff: boolean
|
||||
}
|
||||
|
||||
const vessels: VesselInfo[] = [
|
||||
{ name: '영풍호', lon: 128.72, lat: 34.74, aisOff: false },
|
||||
{ name: '불명-A', lon: 128.82, lat: 34.65, aisOff: true },
|
||||
{ name: '금성호', lon: 128.60, lat: 34.62, aisOff: false },
|
||||
{ name: '불명-B', lon: 128.92, lat: 34.70, aisOff: true },
|
||||
{ name: '태양호', lon: 128.66, lat: 34.58, aisOff: false },
|
||||
]
|
||||
|
||||
interface ZoneInfo {
|
||||
id: string
|
||||
lon: number
|
||||
lat: number
|
||||
radius: number
|
||||
color: [number, number, number]
|
||||
}
|
||||
|
||||
const searchZones: ZoneInfo[] = [
|
||||
{ id: 'A', lon: 128.70, lat: 34.72, radius: 3000, color: [6, 182, 212] },
|
||||
{ id: 'B', lon: 128.88, lat: 34.60, radius: 2500, color: [249, 115, 22] },
|
||||
{ id: 'C', lon: 128.62, lat: 34.56, radius: 2000, color: [234, 179, 8] },
|
||||
]
|
||||
|
||||
const oilSpill = { lon: 128.85, lat: 34.58 }
|
||||
const hnsPoint = { lon: 128.58, lat: 34.52 }
|
||||
|
||||
interface AlertItem {
|
||||
time: string
|
||||
type: 'warning' | 'info' | 'danger'
|
||||
@ -36,11 +102,22 @@ const alerts: AlertItem[] = [
|
||||
{ time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' },
|
||||
]
|
||||
|
||||
// ── 유틸 ────────────────────────────────────────────────
|
||||
function hexToRgba(hex: string): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return [r, g, b, 255]
|
||||
}
|
||||
|
||||
// ── 컴포넌트 ────────────────────────────────────────────
|
||||
export function RealtimeDrone() {
|
||||
const [reconProgress, setReconProgress] = useState(0)
|
||||
const [reconDone, setReconDone] = useState(false)
|
||||
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
|
||||
const [animFrame, setAnimFrame] = useState(0)
|
||||
|
||||
// 3D 재구성 진행률
|
||||
useEffect(() => {
|
||||
if (reconDone) return
|
||||
const timer = setInterval(() => {
|
||||
@ -56,6 +133,301 @@ export function RealtimeDrone() {
|
||||
return () => clearInterval(timer)
|
||||
}, [reconDone])
|
||||
|
||||
// 애니메이션 루프 (~20fps)
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
let raf: number
|
||||
const tick = () => {
|
||||
frame++
|
||||
if (frame % 3 === 0) setAnimFrame(f => f + 1)
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
const activeDrones = useMemo(() => drones.filter(d => d.status !== 'charging'), [])
|
||||
|
||||
// ── deck.gl 레이어 ──────────────────────────────────
|
||||
const deckLayers = useMemo(() => {
|
||||
const t = animFrame * 0.05
|
||||
|
||||
// 탐색 구역 (반투명 원 + 테두리)
|
||||
const zoneFillLayer = new ScatterplotLayer<ZoneInfo>({
|
||||
id: 'search-zones-fill',
|
||||
data: searchZones,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getRadius: d => d.radius + Math.sin(t + searchZones.indexOf(d)) * 100,
|
||||
getFillColor: d => [...d.color, 15],
|
||||
getLineColor: d => [...d.color, 80],
|
||||
getLineWidth: 2,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 30,
|
||||
lineWidthMinPixels: 1.5,
|
||||
})
|
||||
|
||||
// 구역 라벨
|
||||
const zoneLabels = new TextLayer<ZoneInfo>({
|
||||
id: 'zone-labels',
|
||||
data: searchZones,
|
||||
getPosition: d => [d.lon, d.lat + 0.025],
|
||||
getText: d => `${d.id}구역`,
|
||||
getColor: d => [...d.color, 180],
|
||||
getSize: 12,
|
||||
fontFamily: 'Noto Sans KR, sans-serif',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
})
|
||||
|
||||
// 유류 확산 (동심원 3개)
|
||||
const oilRings = [0, 1, 2].map(ring =>
|
||||
new ScatterplotLayer({
|
||||
id: `oil-spill-${ring}`,
|
||||
data: [oilSpill],
|
||||
getPosition: () => [oilSpill.lon, oilSpill.lat],
|
||||
getRadius: 800 + ring * 500 + Math.sin(t * 0.5 + ring) * 80,
|
||||
getFillColor: [249, 115, 22, Math.max(4, 20 - ring * 6)],
|
||||
getLineColor: [249, 115, 22, ring === 0 ? 120 : 40],
|
||||
getLineWidth: ring === 0 ? 2 : 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 15,
|
||||
lineWidthMinPixels: ring === 0 ? 1.5 : 0.8,
|
||||
}),
|
||||
)
|
||||
|
||||
// 유류 확산 라벨
|
||||
const oilLabel = new TextLayer({
|
||||
id: 'oil-label',
|
||||
data: [oilSpill],
|
||||
getPosition: () => [oilSpill.lon, oilSpill.lat - 0.015],
|
||||
getText: () => '유류확산',
|
||||
getColor: [249, 115, 22, 200],
|
||||
getSize: 11,
|
||||
fontFamily: 'Noto Sans KR, sans-serif',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
})
|
||||
|
||||
// HNS 의심
|
||||
const hnsLayer = new ScatterplotLayer({
|
||||
id: 'hns-point',
|
||||
data: [hnsPoint],
|
||||
getPosition: () => [hnsPoint.lon, hnsPoint.lat],
|
||||
getRadius: 400 + Math.sin(t * 1.2) * 80,
|
||||
getFillColor: [234, 179, 8, 50],
|
||||
getLineColor: [234, 179, 8, 100],
|
||||
getLineWidth: 1.5,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 6,
|
||||
lineWidthMinPixels: 1,
|
||||
})
|
||||
const hnsCore = new ScatterplotLayer({
|
||||
id: 'hns-core',
|
||||
data: [hnsPoint],
|
||||
getPosition: () => [hnsPoint.lon, hnsPoint.lat],
|
||||
getRadius: 150,
|
||||
getFillColor: [234, 179, 8, 200],
|
||||
filled: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 4,
|
||||
})
|
||||
const hnsLabel = new TextLayer({
|
||||
id: 'hns-label',
|
||||
data: [hnsPoint],
|
||||
getPosition: () => [hnsPoint.lon, hnsPoint.lat - 0.008],
|
||||
getText: () => 'HNS 의심',
|
||||
getColor: [234, 179, 8, 180],
|
||||
getSize: 10,
|
||||
fontFamily: 'Noto Sans KR, sans-serif',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
})
|
||||
|
||||
// 선박 — AIS OFF는 red 경고 원
|
||||
const vesselAlertLayer = new ScatterplotLayer<VesselInfo>({
|
||||
id: 'vessel-alert',
|
||||
data: vessels.filter(v => v.aisOff),
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getRadius: 600 + Math.sin(t * 1.5) * 150,
|
||||
getFillColor: [239, 68, 68, 20],
|
||||
getLineColor: [239, 68, 68, 60],
|
||||
getLineWidth: 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 10,
|
||||
lineWidthMinPixels: 0.8,
|
||||
})
|
||||
const vesselLayer = new ScatterplotLayer<VesselInfo>({
|
||||
id: 'vessels',
|
||||
data: vessels,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getRadius: 200,
|
||||
getFillColor: d => d.aisOff ? [239, 68, 68, 255] : [96, 165, 250, 255],
|
||||
getLineColor: d => d.aisOff ? [239, 68, 68, 120] : [96, 165, 250, 80],
|
||||
getLineWidth: 1.5,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 5,
|
||||
lineWidthMinPixels: 1,
|
||||
})
|
||||
const vesselLabels = new TextLayer<VesselInfo>({
|
||||
id: 'vessel-labels',
|
||||
data: vessels,
|
||||
getPosition: d => [d.lon, d.lat + 0.005],
|
||||
getText: d => d.name,
|
||||
getColor: [255, 255, 255, 190],
|
||||
getSize: 11,
|
||||
fontFamily: 'Noto Sans KR, sans-serif',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
})
|
||||
|
||||
// 드론 간 메시 링크
|
||||
const droneLinks: { path: [number, number][] }[] = []
|
||||
for (let i = 0; i < activeDrones.length; i++) {
|
||||
for (let j = i + 1; j < activeDrones.length; j++) {
|
||||
const a = activeDrones[i]
|
||||
const b = activeDrones[j]
|
||||
droneLinks.push({
|
||||
path: [
|
||||
[a.lon + Math.sin(t + i * 2) * 0.002, a.lat + Math.cos(t * 0.8 + i) * 0.001],
|
||||
[b.lon + Math.sin(t + j * 2) * 0.002, b.lat + Math.cos(t * 0.8 + j) * 0.001],
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
const linkLayer = new PathLayer({
|
||||
id: 'drone-links',
|
||||
data: droneLinks,
|
||||
getPath: d => d.path,
|
||||
getColor: [77, 208, 225, 35],
|
||||
getWidth: 1,
|
||||
getDashArray: [6, 8],
|
||||
dashJustified: true,
|
||||
widthMinPixels: 0.7,
|
||||
})
|
||||
|
||||
// 드론 글로우 (뒤쪽 큰 원)
|
||||
const droneGlowLayer = new ScatterplotLayer<DroneInfo>({
|
||||
id: 'drone-glow',
|
||||
data: activeDrones,
|
||||
getPosition: d => {
|
||||
const i = activeDrones.indexOf(d)
|
||||
return [
|
||||
d.lon + Math.sin(t + i * 2) * 0.002,
|
||||
d.lat + Math.cos(t * 0.8 + i) * 0.001,
|
||||
]
|
||||
},
|
||||
getRadius: d => selectedDrone === d.id ? 500 : 350,
|
||||
getFillColor: d => {
|
||||
const [r, g, b] = hexToRgba(d.color)
|
||||
return [r, g, b, selectedDrone === d.id ? 40 : 20]
|
||||
},
|
||||
filled: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: selectedDrone ? 12 : 8,
|
||||
updateTriggers: {
|
||||
getPosition: [animFrame],
|
||||
getFillColor: [selectedDrone],
|
||||
getRadius: [selectedDrone],
|
||||
},
|
||||
})
|
||||
|
||||
// 드론 본체
|
||||
const droneLayer = new ScatterplotLayer<DroneInfo>({
|
||||
id: 'drones',
|
||||
data: activeDrones,
|
||||
getPosition: d => {
|
||||
const i = activeDrones.indexOf(d)
|
||||
return [
|
||||
d.lon + Math.sin(t + i * 2) * 0.002,
|
||||
d.lat + Math.cos(t * 0.8 + i) * 0.001,
|
||||
]
|
||||
},
|
||||
getRadius: d => selectedDrone === d.id ? 200 : 150,
|
||||
getFillColor: d => hexToRgba(d.color),
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
getLineWidth: d => selectedDrone === d.id ? 2 : 1,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: selectedDrone ? 6 : 4,
|
||||
lineWidthMinPixels: 1,
|
||||
pickable: true,
|
||||
onClick: (info: PickingInfo<DroneInfo>) => {
|
||||
if (info.object) setSelectedDrone(info.object.id)
|
||||
},
|
||||
updateTriggers: {
|
||||
getPosition: [animFrame],
|
||||
getRadius: [selectedDrone],
|
||||
getLineWidth: [selectedDrone],
|
||||
},
|
||||
})
|
||||
|
||||
// 드론 라벨
|
||||
const droneLabels = new TextLayer<DroneInfo>({
|
||||
id: 'drone-labels',
|
||||
data: activeDrones,
|
||||
getPosition: d => {
|
||||
const i = activeDrones.indexOf(d)
|
||||
return [
|
||||
d.lon + Math.sin(t + i * 2) * 0.002,
|
||||
d.lat + Math.cos(t * 0.8 + i) * 0.001 + 0.006,
|
||||
]
|
||||
},
|
||||
getText: d => d.id,
|
||||
getColor: d => {
|
||||
const [r, g, b] = hexToRgba(d.color)
|
||||
return selectedDrone === d.id ? [255, 255, 255, 255] : [r, g, b, 230]
|
||||
},
|
||||
getSize: d => selectedDrone === d.id ? 13 : 10,
|
||||
fontFamily: 'Outfit, monospace',
|
||||
fontWeight: 700,
|
||||
getTextAnchor: 'middle',
|
||||
getAlignmentBaseline: 'center',
|
||||
billboard: false,
|
||||
updateTriggers: {
|
||||
getPosition: [animFrame],
|
||||
getColor: [selectedDrone],
|
||||
getSize: [selectedDrone],
|
||||
},
|
||||
})
|
||||
|
||||
return [
|
||||
zoneFillLayer,
|
||||
zoneLabels,
|
||||
...oilRings,
|
||||
oilLabel,
|
||||
hnsLayer,
|
||||
hnsCore,
|
||||
hnsLabel,
|
||||
vesselAlertLayer,
|
||||
vesselLayer,
|
||||
vesselLabels,
|
||||
linkLayer,
|
||||
droneGlowLayer,
|
||||
droneLayer,
|
||||
droneLabels,
|
||||
]
|
||||
}, [animFrame, selectedDrone, activeDrones])
|
||||
|
||||
// ── UI 유틸 ───────────────────────────────────────────
|
||||
const statusLabel = (s: string) => {
|
||||
if (s === 'active') return { text: '비행중', cls: 'text-status-green' }
|
||||
if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' }
|
||||
@ -70,32 +442,23 @@ export function RealtimeDrone() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
|
||||
{/* Map Area */}
|
||||
<div className="flex-1 relative bg-bg-0 overflow-hidden">
|
||||
{/* Simulated map background */}
|
||||
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.3) 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
|
||||
{/* Coastline hint */}
|
||||
<div className="absolute" style={{ top: '20%', left: '5%', width: '40%', height: '60%', border: '1px solid rgba(34,197,94,0.15)', borderRadius: '40% 60% 50% 30%' }} />
|
||||
{/* Drone position markers */}
|
||||
{drones.filter(d => d.status !== 'charging').map((d, i) => (
|
||||
<div
|
||||
key={d.id}
|
||||
className="absolute cursor-pointer"
|
||||
style={{ top: `${25 + i * 12}%`, left: `${30 + i * 10}%` }}
|
||||
onClick={() => setSelectedDrone(d.id)}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-full animate-pulse-dot" style={{ background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
||||
<div className="absolute -top-4 left-4 text-[8px] font-bold font-mono whitespace-nowrap" style={{ color: d.color }}>{d.id}</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Oil spill areas */}
|
||||
<div className="absolute" style={{ top: '35%', left: '45%', width: '120px', height: '80px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '40% 60% 50% 40%' }} />
|
||||
</div>
|
||||
{/* 지도 영역 */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<Map
|
||||
initialViewState={{
|
||||
longitude: 128.75,
|
||||
latitude: 34.64,
|
||||
zoom: 10,
|
||||
}}
|
||||
mapStyle={BASE_STYLE}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
>
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
</Map>
|
||||
|
||||
{/* Overlay Stats */}
|
||||
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2]">
|
||||
{/* 오버레이 통계 */}
|
||||
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2] pointer-events-none">
|
||||
{[
|
||||
{ label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' },
|
||||
{ label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' },
|
||||
@ -112,7 +475,7 @@ export function RealtimeDrone() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3D Reconstruction Progress */}
|
||||
{/* 3D 재구성 진행률 */}
|
||||
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D 재구성</span>
|
||||
@ -128,7 +491,7 @@ export function RealtimeDrone() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Feed Panel */}
|
||||
{/* 실시간 영상 패널 */}
|
||||
{selectedDrone && (() => {
|
||||
const drone = drones.find(d => d.id === selectedDrone)
|
||||
if (!drone) return null
|
||||
@ -143,15 +506,13 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
|
||||
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
|
||||
{/* Simulated video feed */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
|
||||
</div>
|
||||
{/* HUD overlay */}
|
||||
<div className="absolute top-1.5 left-2 z-[2]">
|
||||
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
|
||||
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
|
||||
<div className="text-[7px] text-text-3 font-mono mt-0.5">34.82°N, 128.95°E</div>
|
||||
<div className="text-[7px] text-text-3 font-mono mt-0.5">{drone.lat.toFixed(2)}°N, {drone.lon.toFixed(2)}°E</div>
|
||||
</div>
|
||||
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
|
||||
@ -183,11 +544,11 @@ export function RealtimeDrone() {
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
{/* 우측 사이드바 */}
|
||||
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
|
||||
{/* Drone Swarm Status */}
|
||||
{/* 군집 드론 현황 */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · 4/6 운용</div>
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">군집 드론 현황 · {activeDrones.length}/{drones.length} 운용</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{drones.map(d => {
|
||||
const st = statusLabel(d.status)
|
||||
@ -214,7 +575,7 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-Angle Analysis */}
|
||||
{/* 다각화 분석 */}
|
||||
<div className="p-2.5 px-3 border-b border-border">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">다각화 분석</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@ -234,7 +595,7 @@ export function RealtimeDrone() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Alerts */}
|
||||
{/* 실시간 경보 */}
|
||||
<div className="p-2.5 px-3 flex-1 overflow-auto">
|
||||
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">실시간 경보</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
interface ReconItem {
|
||||
id: string
|
||||
@ -18,110 +18,241 @@ const reconItems: ReconItem[] = [
|
||||
{ id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' },
|
||||
]
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s |= 0; s = s + 0x6D2B79F5 | 0;
|
||||
let t = Math.imul(s ^ s >>> 15, 1 | s);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||
const isProcessing = status === 'processing'
|
||||
const isWire = viewMode === 'wire'
|
||||
const isPoint = viewMode === 'point'
|
||||
const isProcessing = status === 'processing';
|
||||
const isWire = viewMode === 'wire';
|
||||
const isPoint = viewMode === 'point';
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [vesselPoints] = useState(() =>
|
||||
Array.from({ length: 300 }, (_, i) => {
|
||||
const x = 35 + Math.random() * 355
|
||||
const y = 15 + Math.random() * 160
|
||||
const inHull = y > 60 && y < 175 && x > 35 && x < 390
|
||||
const inBridge = x > 260 && x < 330 && y > 25 && y < 60
|
||||
if (!inHull && !inBridge && Math.random() > 0.15) return null
|
||||
const alpha = 0.15 + Math.random() * 0.55
|
||||
const r = 0.8 + Math.random() * 0.8
|
||||
return { i, x, y, r, alpha }
|
||||
})
|
||||
)
|
||||
const W = 420;
|
||||
const H = 200;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = `${W}px`;
|
||||
canvas.style.height = `${H}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
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 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)`;
|
||||
|
||||
// 수선 (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([]);
|
||||
|
||||
// 선체 (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();
|
||||
|
||||
// 선체 하부
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 선수/선미 표시
|
||||
ctx.fillStyle = `${cyanFull}0.3)`;
|
||||
ctx.font = '8px var(--fM, monospace)';
|
||||
ctx.fillText('선수', 395, 95);
|
||||
ctx.fillText('선미', 15, 95);
|
||||
|
||||
// 측정선 (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';
|
||||
|
||||
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]);
|
||||
|
||||
// 선박 SVG 와이어프레임/솔리드 3D 투시
|
||||
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: '420px', height: '200px' }}>
|
||||
<svg viewBox="0 0 420 200" width="420" height="200" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||
{/* 수선 (waterline) */}
|
||||
<ellipse cx="210" cy="165" rx="200" ry="12" fill="none" stroke="rgba(6,182,212,0.15)" strokeWidth="0.5" strokeDasharray="4 2" />
|
||||
<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 }}
|
||||
/>
|
||||
|
||||
{/* 선체 (hull) - 3D 효과 */}
|
||||
<path d="M 30 140 Q 40 170 100 175 L 320 175 Q 380 170 395 140 L 390 100 Q 385 85 370 80 L 50 80 Q 35 85 30 100 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.2)' : 'rgba(6,182,212,0.5)'}
|
||||
strokeWidth={isWire ? '0.8' : '1.2'} />
|
||||
|
||||
{/* 선체 하부 */}
|
||||
<path d="M 30 140 Q 20 155 60 168 L 100 175 M 395 140 Q 405 155 360 168 L 320 175"
|
||||
fill="none" stroke="rgba(6,182,212,0.3)" strokeWidth="0.7" />
|
||||
|
||||
{/* 갑판 (deck) */}
|
||||
<path d="M 50 80 Q 45 65 55 60 L 365 60 Q 375 65 370 80"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.05)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.45)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
|
||||
{/* 선교 (bridge) */}
|
||||
<rect x="260" y="25" width="70" height="35" rx="2"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.1)'}
|
||||
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.5)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
{/* 선교 창문 */}
|
||||
{!isPoint && <g stroke="rgba(6,182,212,0.3)" strokeWidth="0.5" fill="none">
|
||||
<rect x="268" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="282" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="296" y="30" width="10" height="6" rx="1" />
|
||||
<rect x="310" y="30" width="10" height="6" rx="1" />
|
||||
</g>}
|
||||
|
||||
{/* 마스트 */}
|
||||
<line x1="295" y1="25" x2="295" y2="8" stroke="rgba(6,182,212,0.4)" strokeWidth="1" />
|
||||
<line x1="288" y1="12" x2="302" y2="12" stroke="rgba(6,182,212,0.3)" strokeWidth="0.8" />
|
||||
|
||||
{/* 연통 (funnel) */}
|
||||
<rect x="235" y="38" width="18" height="22" rx="1"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.1)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.4)'}
|
||||
strokeWidth={isWire ? '0.8' : '1'} />
|
||||
|
||||
{/* 화물 크레인 */}
|
||||
<g stroke={isProcessing ? 'rgba(249,115,22,0.15)' : 'rgba(249,115,22,0.4)'} strokeWidth="0.8" fill="none">
|
||||
<line x1="150" y1="60" x2="150" y2="20" />
|
||||
<line x1="150" y1="22" x2="120" y2="40" />
|
||||
<line x1="180" y1="60" x2="180" y2="25" />
|
||||
<line x1="180" y1="27" x2="155" y2="42" />
|
||||
</g>
|
||||
|
||||
{/* 선체 리브 (와이어프레임 / 포인트 모드) */}
|
||||
{(isWire || isPoint) && <g stroke="rgba(6,182,212,0.15)" strokeWidth="0.4">
|
||||
{[80, 120, 160, 200, 240, 280, 320, 360].map(x => (
|
||||
<line key={x} x1={x} y1="60" x2={x} y2="175" />
|
||||
))}
|
||||
{[80, 100, 120, 140, 160].map(y => (
|
||||
<line key={y} x1="30" y1={y} x2="395" y2={y} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 포인트 클라우드 모드 */}
|
||||
{isPoint && <g>
|
||||
{vesselPoints.map(p => p && (
|
||||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 선수/선미 표시 */}
|
||||
<text x="395" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선수</text>
|
||||
<text x="15" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)">선미</text>
|
||||
|
||||
{/* 측정선 (3D 모드) */}
|
||||
{viewMode === '3d' && <>
|
||||
<line x1="30" y1="185" x2="395" y2="185" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="200" y="195" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">84.7m</text>
|
||||
<line x1="405" y1="60" x2="405" y2="175" stroke="rgba(249,115,22,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="415" y="120" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="start" transform="rotate(90, 415, 120)">14.2m</text>
|
||||
</>}
|
||||
</svg>
|
||||
|
||||
{/* 처리중 오버레이 */}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
@ -134,96 +265,217 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
|
||||
const isProcessing = status === 'processing'
|
||||
const isWire = viewMode === 'wire'
|
||||
const isPoint = viewMode === 'point'
|
||||
const isProcessing = status === 'processing';
|
||||
const isWire = viewMode === 'wire';
|
||||
const isPoint = viewMode === 'point';
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [pollutionPoints] = useState(() =>
|
||||
Array.from({ length: 400 }, (_, i) => {
|
||||
const cx = 190, cy = 145, rx = 130, ry = 75
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const r = Math.sqrt(Math.random())
|
||||
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) return null
|
||||
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2)
|
||||
const intensity = Math.max(0.1, 1 - dist)
|
||||
const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})`
|
||||
const circleR = 0.6 + Math.random() * 1.2
|
||||
return { i, x, y, r: circleR, color }
|
||||
})
|
||||
)
|
||||
const W = 380;
|
||||
const H = 260;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = `${W}px`;
|
||||
canvas.style.height = `${H}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
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,';
|
||||
|
||||
// 해수면 그리드
|
||||
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();
|
||||
}
|
||||
|
||||
// 와이어프레임 / 포인트 모드 등고선 타원
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// 유막 메인 형태 (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();
|
||||
|
||||
// 유막 두께 등고선
|
||||
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.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';
|
||||
|
||||
// 측정선
|
||||
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';
|
||||
|
||||
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]);
|
||||
|
||||
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: '380px', height: '260px' }}>
|
||||
<svg viewBox="0 0 380 260" width="380" height="260" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
|
||||
{/* 해수면 그리드 */}
|
||||
<g stroke="rgba(6,182,212,0.08)" strokeWidth="0.4">
|
||||
{Array.from({ length: 15 }, (_, i) => <line key={`h${i}`} x1="0" y1={i * 20} x2="380" y2={i * 20} />)}
|
||||
{Array.from({ length: 20 }, (_, i) => <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="260" />)}
|
||||
</g>
|
||||
<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 }}
|
||||
/>
|
||||
|
||||
{/* 유막 메인 형태 - 불규칙 blob */}
|
||||
<path d="M 120 80 Q 80 90 70 120 Q 55 155 80 180 Q 100 205 140 210 Q 180 220 220 205 Q 270 195 300 170 Q 320 145 310 115 Q 300 85 270 75 Q 240 65 200 70 Q 160 68 120 80 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.45)'}
|
||||
strokeWidth={isWire ? '0.8' : '1.5'} />
|
||||
|
||||
{/* 유막 두께 등고선 */}
|
||||
<path d="M 155 100 Q 125 115 120 140 Q 115 165 135 180 Q 155 195 190 190 Q 230 185 255 165 Q 270 145 260 120 Q 250 100 225 95 Q 195 88 155 100 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(249,115,22,0.08)'}
|
||||
stroke={isProcessing ? 'rgba(249,115,22,0.12)' : 'rgba(249,115,22,0.35)'}
|
||||
strokeWidth="0.8" strokeDasharray={isWire ? '4 2' : 'none'} />
|
||||
|
||||
{/* 유막 최고 두께 핵심 */}
|
||||
<path d="M 175 120 Q 160 130 165 150 Q 170 170 195 170 Q 220 168 230 150 Q 235 130 220 120 Q 205 110 175 120 Z"
|
||||
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.15)'}
|
||||
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.5)'}
|
||||
strokeWidth="0.8" />
|
||||
|
||||
{/* 확산 방향 화살표 */}
|
||||
<g stroke="rgba(249,115,22,0.5)" strokeWidth="1" fill="rgba(249,115,22,0.5)">
|
||||
<line x1="250" y1="140" x2="330" y2="120" />
|
||||
<polygon points="330,120 322,115 324,123" />
|
||||
<text x="335" y="122" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)">ESE 0.3km/h</text>
|
||||
</g>
|
||||
|
||||
{/* 와이어프레임 추가 등고선 */}
|
||||
{(isWire || isPoint) && <g stroke="rgba(239,68,68,0.12)" strokeWidth="0.3">
|
||||
<ellipse cx="190" cy="145" rx="140" ry="80" fill="none" />
|
||||
<ellipse cx="190" cy="145" rx="100" ry="55" fill="none" />
|
||||
<ellipse cx="190" cy="145" rx="60" ry="35" fill="none" />
|
||||
</g>}
|
||||
|
||||
{/* 포인트 클라우드 */}
|
||||
{isPoint && <g>
|
||||
{pollutionPoints.map(p => p && (
|
||||
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
|
||||
))}
|
||||
</g>}
|
||||
|
||||
{/* 두께 색상 범례 */}
|
||||
{viewMode === '3d' && <>
|
||||
<text x="165" y="148" fill="rgba(239,68,68,0.7)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">3.2mm</text>
|
||||
<text x="130" y="165" fill="rgba(249,115,22,0.5)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">1.5mm</text>
|
||||
<text x="95" y="130" fill="rgba(234,179,8,0.4)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">0.3mm</text>
|
||||
</>}
|
||||
|
||||
{/* 측정선 (3D 모드) */}
|
||||
{viewMode === '3d' && <>
|
||||
<line x1="55" y1="240" x2="320" y2="240" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="187" y="252" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">1.24 km</text>
|
||||
<line x1="25" y1="80" x2="25" y2="210" stroke="rgba(59,130,246,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
|
||||
<text x="15" y="150" fill="rgba(59,130,246,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle" transform="rotate(-90, 15, 150)">0.68 km</text>
|
||||
</>}
|
||||
</svg>
|
||||
|
||||
{/* 두께 색상 범례 바 */}
|
||||
{viewMode === '3d' && !isProcessing && (
|
||||
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
<span>0mm</span>
|
||||
@ -244,7 +496,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SensorAnalysis() {
|
||||
|
||||
@ -15,8 +15,86 @@ import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
import { api } from '@common/services/api'
|
||||
import { generateAIBoomLines } from '@common/utils/geo'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 민감자원 타입 + 데모 데이터
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface SensitiveResource {
|
||||
id: string
|
||||
name: string
|
||||
type: 'aquaculture' | 'beach' | 'ecology' | 'intake'
|
||||
lat: number
|
||||
lon: number
|
||||
radiusM: number
|
||||
arrivalTimeH: number
|
||||
}
|
||||
|
||||
const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [
|
||||
{ id: 'aq-1', name: '여수 돌산 양식장', type: 'aquaculture', lat: 34.755, lon: 127.735, radiusM: 800, arrivalTimeH: 3 },
|
||||
{ id: 'bc-1', name: '만성리 해수욕장', type: 'beach', lat: 34.765, lon: 127.765, radiusM: 400, arrivalTimeH: 6 },
|
||||
{ id: 'ec-1', name: '오동도 생태보호구역', type: 'ecology', lat: 34.745, lon: 127.78, radiusM: 600, arrivalTimeH: 12 },
|
||||
{ id: 'aq-2', name: '금오도 전복 양식장', type: 'aquaculture', lat: 34.70, lon: 127.75, radiusM: 700, arrivalTimeH: 8 },
|
||||
{ id: 'bc-2', name: '방죽포 해수욕장', type: 'beach', lat: 34.72, lon: 127.81, radiusM: 350, arrivalTimeH: 10 },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 데모 궤적 생성 (seeded PRNG — deterministic)
|
||||
// ---------------------------------------------------------------------------
|
||||
function mulberry32(seed: number) {
|
||||
return () => {
|
||||
seed |= 0; seed = seed + 0x6D2B79F5 | 0
|
||||
let t = Math.imul(seed ^ seed >>> 15, 1 | seed)
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
function generateDemoTrajectory(
|
||||
incident: { lat: number; lon: number },
|
||||
models: PredictionModel[],
|
||||
durationHours: number
|
||||
): Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> {
|
||||
const result: Array<{ lat: number; lon: number; time: number; particle: number; model: PredictionModel }> = []
|
||||
const PARTICLES_PER_MODEL = 60
|
||||
const TIME_STEP = 3 // hours
|
||||
|
||||
const modelParams: Record<PredictionModel, { bearing: number; speed: number; spread: number; seed: number }> = {
|
||||
KOSPS: { bearing: 42, speed: 0.003, spread: 0.008, seed: 42 },
|
||||
POSEIDON: { bearing: 55, speed: 0.0025, spread: 0.01, seed: 137 },
|
||||
OpenDrift: { bearing: 35, speed: 0.0035, spread: 0.006, seed: 271 },
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
const p = modelParams[model]
|
||||
const rng = mulberry32(p.seed)
|
||||
|
||||
for (let pid = 0; pid < PARTICLES_PER_MODEL; pid++) {
|
||||
const particleAngleOffset = (rng() - 0.5) * 40 // ±20°
|
||||
const particleSpeedFactor = 0.7 + rng() * 0.6 // 0.7~1.3
|
||||
|
||||
for (let t = 0; t <= durationHours; t += TIME_STEP) {
|
||||
const timeFactor = t / durationHours
|
||||
const bearing = (p.bearing + particleAngleOffset) * DEG2RAD
|
||||
|
||||
const dist = p.speed * t * particleSpeedFactor
|
||||
const turbLat = (rng() - 0.5) * p.spread * timeFactor
|
||||
const turbLon = (rng() - 0.5) * p.spread * timeFactor
|
||||
|
||||
const lat = incident.lat + dist * Math.cos(bearing) + turbLat
|
||||
const lon = incident.lon + dist * Math.sin(bearing) / Math.cos(incident.lat * DEG2RAD) + turbLon
|
||||
|
||||
result.push({ lat, lon, time: t, particle: pid, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift']
|
||||
|
||||
@ -33,6 +111,9 @@ export function OilSpillView() {
|
||||
const [oilType, setOilType] = useState('벙커C유')
|
||||
const [spillAmount, setSpillAmount] = useState(100)
|
||||
|
||||
// 민감자원
|
||||
const [sensitiveResources, setSensitiveResources] = useState<SensitiveResource[]>([])
|
||||
|
||||
// 오일펜스 배치 상태
|
||||
const [boomLines, setBoomLines] = useState<BoomLine[]>([])
|
||||
const [algorithmSettings, setAlgorithmSettings] = useState<AlgorithmSettings>({
|
||||
@ -274,9 +355,19 @@ export function OilSpillView() {
|
||||
)
|
||||
|
||||
setOilTrajectory(results.flat())
|
||||
} catch (error) {
|
||||
console.error('시뮬레이션 실행 오류:', error)
|
||||
alert('시뮬레이션 실행 중 오류가 발생했습니다.')
|
||||
} catch {
|
||||
// 백엔드 미구현 — 클라이언트 데모 궤적 fallback
|
||||
console.info('[prediction] 서버 시뮬레이션 미구현, 데모 궤적 생성')
|
||||
const models = Array.from(selectedModels)
|
||||
const demoTrajectory = generateDemoTrajectory(incidentCoord, models, predictionTime)
|
||||
setOilTrajectory(demoTrajectory)
|
||||
|
||||
// AI 방어선 자동 생성
|
||||
const demoBooms = generateAIBoomLines(demoTrajectory, incidentCoord, algorithmSettings)
|
||||
setBoomLines(demoBooms)
|
||||
|
||||
// 민감자원 로드
|
||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||
} finally {
|
||||
setIsRunningSimulation(false)
|
||||
}
|
||||
@ -345,6 +436,7 @@ export function OilSpillView() {
|
||||
drawingPoints={drawingPoints}
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
sensitiveResources={sensitiveResources}
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
|
||||
isActive: true,
|
||||
ships: replayShips,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user