Merge pull request 'feat(map): Phase 6 MapLibre GL JS + deck.gl 전환 + 탭 시각화 개선' (#51) from feature/phase6-maplibre-deckgl into develop

Reviewed-on: #51
This commit is contained in:
htlee 2026-03-01 09:02:05 +09:00
커밋 60d8d1af95
6개의 변경된 파일1082개의 추가작업 그리고 266개의 파일을 삭제

파일 보기

@ -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: '&copy; OSM &copy; 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,