diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 6549140..040407f 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -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 = { 'MEDIUM': '๋ณดํ†ต', } +const SENSITIVE_COLORS: Record = { + 'aquaculture': '#22c55e', + 'beach': '#0ea5e9', + 'ecology': '#eab308', + 'intake': '#a855f7', +} +const SENSITIVE_ICONS: Record = { + '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: ( +
+
+ {SENSITIVE_ICONS[d.type]} + {d.name} +
+
+ ๋ฐ˜๊ฒฝ: {d.radiusM}m
+ ๋„๋‹ฌ ์˜ˆ์ƒ: {d.arrivalTimeH}h +
+
+ ), + }) + } + }, + }) + ) + + // ๋ฏผ๊ฐ์ž์› ์ค‘์‹ฌ ๋งˆ์ปค + 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 ( diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx index 26a171c..1688a40 100755 --- a/frontend/src/tabs/aerial/components/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -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('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 + case 'satellite': + return + case 'cctv': + return + case 'analysis': + return + case 'realtime': + return + case 'sensor': + return + case 'media': + default: + return } - }, [activeSubTab]) - - if (activeSubTab === 'theory') { - return - } - if (activeSubTab === 'satellite') { - return ( -
-
-
- ) - } - if (activeSubTab === 'cctv') { - return ( -
-
-
- ) } return (
-
- {activeTab === 'media' && } - {activeTab === 'analysis' && } - {activeTab === 'realtime' && } - {activeTab === 'sensor' && } -
+ {renderContent()}
) diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index 3061509..888c44b 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -248,17 +248,17 @@ export function CctvView() {
์ง€๋„ ์˜์—ญ
{/* ๊ฐ„๋žต ์ง€๋„ ํ‘œํ˜„ */}
- {cctvCameras.filter(c => c.status === 'live').slice(0, 6).map((c, i) => ( + {cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => (
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) }} > diff --git a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx index 1a00917..adb6892 100644 --- a/frontend/src/tabs/aerial/components/RealtimeDrone.tsx +++ b/frontend/src/tabs/aerial/components/RealtimeDrone.tsx @@ -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(() => 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(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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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) => { + if (info.object) setSelectedDrone(info.object.id) + }, + updateTriggers: { + getPosition: [animFrame], + getRadius: [selectedDrone], + getLineWidth: [selectedDrone], + }, + }) + + // ๋“œ๋ก  ๋ผ๋ฒจ + const droneLabels = new TextLayer({ + 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 (
- {/* Map Area */} -
- {/* Simulated map background */} -
- {/* Grid lines */} -
- {/* Coastline hint */} -
- {/* Drone position markers */} - {drones.filter(d => d.status !== 'charging').map((d, i) => ( -
setSelectedDrone(d.id)} - > -
-
{d.id}
-
- ))} - {/* Oil spill areas */} -
-
+ {/* ์ง€๋„ ์˜์—ญ */} +
+ + + - {/* Overlay Stats */} -
+ {/* ์˜ค๋ฒ„๋ ˆ์ด ํ†ต๊ณ„ */} +
{[ { label: 'ํƒ์ง€ ๊ฐ์ฒด', value: '847', unit: '๊ฑด', color: 'text-primary-blue' }, { label: '์‹๋ณ„ ์„ ๋ฐ•', value: '312', unit: '์ฒ™', color: 'text-primary-cyan' }, @@ -112,7 +475,7 @@ export function RealtimeDrone() { ))}
- {/* 3D Reconstruction Progress */} + {/* 3D ์žฌ๊ตฌ์„ฑ ์ง„ํ–‰๋ฅ  */}
๐ŸงŠ 3D ์žฌ๊ตฌ์„ฑ @@ -128,7 +491,7 @@ export function RealtimeDrone() { )}
- {/* Live Feed Panel */} + {/* ์‹ค์‹œ๊ฐ„ ์˜์ƒ ํŒจ๋„ */} {selectedDrone && (() => { const drone = drones.find(d => d.id === selectedDrone) if (!drone) return null @@ -143,15 +506,13 @@ export function RealtimeDrone() {
- {/* Simulated video feed */}
LIVE FEED
- {/* HUD overlay */}
{drone.id} {drone.sensor} -
34.82ยฐN, 128.95ยฐE
+
{drone.lat.toFixed(2)}ยฐN, {drone.lon.toFixed(2)}ยฐE
REC @@ -183,11 +544,11 @@ export function RealtimeDrone() { })()}
- {/* Right Sidebar */} + {/* ์šฐ์ธก ์‚ฌ์ด๋“œ๋ฐ” */}
- {/* Drone Swarm Status */} + {/* ๊ตฐ์ง‘ ๋“œ๋ก  ํ˜„ํ™ฉ */}
-
๊ตฐ์ง‘ ๋“œ๋ก  ํ˜„ํ™ฉ ยท 4/6 ์šด์šฉ
+
๊ตฐ์ง‘ ๋“œ๋ก  ํ˜„ํ™ฉ ยท {activeDrones.length}/{drones.length} ์šด์šฉ
{drones.map(d => { const st = statusLabel(d.status) @@ -214,7 +575,7 @@ export function RealtimeDrone() {
- {/* Multi-Angle Analysis */} + {/* ๋‹ค๊ฐํ™” ๋ถ„์„ */}
๋‹ค๊ฐํ™” ๋ถ„์„
@@ -234,7 +595,7 @@ export function RealtimeDrone() {
- {/* Real-time Alerts */} + {/* ์‹ค์‹œ๊ฐ„ ๊ฒฝ๋ณด */}
์‹ค์‹œ๊ฐ„ ๊ฒฝ๋ณด
diff --git a/frontend/src/tabs/aerial/components/SensorAnalysis.tsx b/frontend/src/tabs/aerial/components/SensorAnalysis.tsx index fc3e5c3..4039bc2 100644 --- a/frontend/src/tabs/aerial/components/SensorAnalysis.tsx +++ b/frontend/src/tabs/aerial/components/SensorAnalysis.tsx @@ -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(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 (
-
- - {/* ์ˆ˜์„  (waterline) */} - +
+ - {/* ์„ ์ฒด (hull) - 3D ํšจ๊ณผ */} - - - {/* ์„ ์ฒด ํ•˜๋ถ€ */} - - - {/* ๊ฐ‘ํŒ (deck) */} - - - {/* ์„ ๊ต (bridge) */} - - {/* ์„ ๊ต ์ฐฝ๋ฌธ */} - {!isPoint && - - - - - } - - {/* ๋งˆ์ŠคํŠธ */} - - - - {/* ์—ฐํ†ต (funnel) */} - - - {/* ํ™”๋ฌผ ํฌ๋ ˆ์ธ */} - - - - - - - - {/* ์„ ์ฒด ๋ฆฌ๋ธŒ (์™€์ด์–ดํ”„๋ ˆ์ž„ / ํฌ์ธํŠธ ๋ชจ๋“œ) */} - {(isWire || isPoint) && - {[80, 120, 160, 200, 240, 280, 320, 360].map(x => ( - - ))} - {[80, 100, 120, 140, 160].map(y => ( - - ))} - } - - {/* ํฌ์ธํŠธ ํด๋ผ์šฐ๋“œ ๋ชจ๋“œ */} - {isPoint && - {vesselPoints.map(p => p && ( - - ))} - } - - {/* ์„ ์ˆ˜/์„ ๋ฏธ ํ‘œ์‹œ */} - ์„ ์ˆ˜ - ์„ ๋ฏธ - - {/* ์ธก์ •์„  (3D ๋ชจ๋“œ) */} - {viewMode === '3d' && <> - - 84.7m - - 14.2m - } - - - {/* ์ฒ˜๋ฆฌ์ค‘ ์˜ค๋ฒ„๋ ˆ์ด */} {isProcessing && (
@@ -134,96 +265,217 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string )}
- ) + ); } 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(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 (
-
- - {/* ํ•ด์ˆ˜๋ฉด ๊ทธ๋ฆฌ๋“œ */} - - {Array.from({ length: 15 }, (_, i) => )} - {Array.from({ length: 20 }, (_, i) => )} - +
+ - {/* ์œ ๋ง‰ ๋ฉ”์ธ ํ˜•ํƒœ - ๋ถˆ๊ทœ์น™ blob */} - - - {/* ์œ ๋ง‰ ๋‘๊ป˜ ๋“ฑ๊ณ ์„  */} - - - {/* ์œ ๋ง‰ ์ตœ๊ณ  ๋‘๊ป˜ ํ•ต์‹ฌ */} - - - {/* ํ™•์‚ฐ ๋ฐฉํ–ฅ ํ™”์‚ดํ‘œ */} - - - - ESE 0.3km/h - - - {/* ์™€์ด์–ดํ”„๋ ˆ์ž„ ์ถ”๊ฐ€ ๋“ฑ๊ณ ์„  */} - {(isWire || isPoint) && - - - - } - - {/* ํฌ์ธํŠธ ํด๋ผ์šฐ๋“œ */} - {isPoint && - {pollutionPoints.map(p => p && ( - - ))} - } - - {/* ๋‘๊ป˜ ์ƒ‰์ƒ ๋ฒ”๋ก€ */} - {viewMode === '3d' && <> - 3.2mm - 1.5mm - 0.3mm - } - - {/* ์ธก์ •์„  (3D ๋ชจ๋“œ) */} - {viewMode === '3d' && <> - - 1.24 km - - 0.68 km - } - - - {/* ๋‘๊ป˜ ์ƒ‰์ƒ ๋ฒ”๋ก€ ๋ฐ” */} {viewMode === '3d' && !isProcessing && (
0mm @@ -244,7 +496,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri )}
- ) + ); } export function SensorAnalysis() { diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index fd84ca3..50dd0df 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -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 = { + 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([]) + // ์˜ค์ผํŽœ์Šค ๋ฐฐ์น˜ ์ƒํƒœ const [boomLines, setBoomLines] = useState([]) const [algorithmSettings, setAlgorithmSettings] = useState({ @@ -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,