From dc82574635700d553f887b4d6983f084193579b6 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Fri, 13 Mar 2026 09:19:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(prediction):=20=EC=98=A4=EC=97=BC=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EB=8B=A4=EA=B0=81=ED=98=95/=EC=9B=90=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오염분석 섹션을 탭 UI로 개편 (다각형 분석 / 원 분석) - 다각형 분석: 지도 클릭으로 꼭짓점 추가 후 분석 실행 - 원 분석: NM 프리셋 버튼(1·3·5·10·15·20·30·50) + 직접 입력, 사고지점 기준 자동 계산 - 분석 결과: 분석면적·오염비율·오염면적·해상잔존량·연안부착량·민감자원 개소 표시 - MapView: 다각형(PolygonLayer) / 원(ScatterplotLayer) 실시간 지도 시각화 - geo.ts: pointInPolygon, polygonAreaKm2, circleAreaKm2 유틸 함수 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../src/common/components/map/MapView.tsx | 108 ++++++++- frontend/src/common/utils/geo.ts | 41 ++++ .../prediction/components/OilSpillView.tsx | 102 +++++++- .../tabs/prediction/components/RightPanel.tsx | 229 +++++++++++++++++- 4 files changed, 464 insertions(+), 16 deletions(-) diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 1e7b7e2..f9c7eca 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' -import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers' +import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core' import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' @@ -189,6 +189,10 @@ interface MapViewProps { mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> onIncidentFlyEnd?: () => void flyToIncident?: { lon: number; lat: number } + drawAnalysisMode?: 'polygon' | 'circle' | null + analysisPolygonPoints?: Array<{ lat: number; lon: number }> + analysisCircleCenter?: { lat: number; lon: number } | null + analysisCircleRadiusM?: number } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -311,6 +315,10 @@ export function MapView({ mapCaptureRef, onIncidentFlyEnd, flyToIncident, + drawAnalysisMode = null, + analysisPolygonPoints = [], + analysisCircleCenter, + analysisCircleRadiusM = 0, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -529,6 +537,91 @@ export function MapView({ ) } + // --- 오염분석 다각형 그리기 --- + if (analysisPolygonPoints.length > 0) { + if (analysisPolygonPoints.length >= 3) { + result.push( + new PolygonLayer({ + id: 'analysis-polygon-fill', + data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [168, 85, 247, 40], + getLineColor: [168, 85, 247, 220], + getLineWidth: 2, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + }) + ) + } + result.push( + new PathLayer({ + id: 'analysis-polygon-outline', + data: [{ + path: [ + ...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]), + ...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []), + ], + }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [168, 85, 247, 220], + getWidth: 2, + getDashArray: [8, 4], + dashJustified: true, + widthMinPixels: 2, + }) + ) + result.push( + new ScatterplotLayer({ + id: 'analysis-polygon-points', + data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })), + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 5, + getFillColor: [168, 85, 247, 255], + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 5, + radiusMaxPixels: 8, + }) + ) + } + + // --- 오염분석 원 그리기 --- + if (analysisCircleCenter) { + result.push( + new ScatterplotLayer({ + id: 'analysis-circle-center', + data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 6, + getFillColor: [168, 85, 247, 255], + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 6, + radiusMaxPixels: 9, + }) + ) + if (analysisCircleRadiusM > 0) { + result.push( + new ScatterplotLayer({ + id: 'analysis-circle-area', + data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: analysisCircleRadiusM, + radiusUnits: 'meters', + getFillColor: [168, 85, 247, 35], + getLineColor: [168, 85, 247, 200], + getLineWidth: 2, + stroked: true, + filled: true, + lineWidthMinPixels: 2, + }) + ) + } + } + // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- if (dispersionHeatmap && dispersionHeatmap.length > 0) { const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration)); @@ -829,6 +922,7 @@ export function MapView({ boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, centerPoints, windData, + analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, ]) // 3D 모드에 따른 지도 스타일 전환 @@ -844,7 +938,7 @@ export function MapView({ }} mapStyle={currentMapStyle} className="w-full h-full" - style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }} + style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }} onClick={handleMapClick} attributionControl={false} preserveDrawingBuffer={true} @@ -928,6 +1022,16 @@ export function MapView({ 오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트) )} + {drawAnalysisMode === 'polygon' && ( +
+ 다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개) +
+ )} + {drawAnalysisMode === 'circle' && ( +
+ {!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'} +
+ )} {/* 기상청 연계 정보 */} diff --git a/frontend/src/common/utils/geo.ts b/frontend/src/common/utils/geo.ts index b522dd8..47d3327 100755 --- a/frontend/src/common/utils/geo.ts +++ b/frontend/src/common/utils/geo.ts @@ -186,6 +186,47 @@ export function generateAIBoomLines( return boomLines } +/** Ray casting — 점이 다각형 내부인지 판정 */ +export function pointInPolygon( + point: { lat: number; lon: number }, + polygon: { lat: number; lon: number }[] +): boolean { + if (polygon.length < 3) return false + let inside = false + const x = point.lon + const y = point.lat + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].lon, yi = polygon[i].lat + const xj = polygon[j].lon, yj = polygon[j].lat + const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) + if (intersect) inside = !inside + } + return inside +} + +/** 다각형 면적 (km²) — Shoelace formula, 구면 보정 포함 */ +export function polygonAreaKm2(polygon: { lat: number; lon: number }[]): number { + if (polygon.length < 3) return 0 + const n = polygon.length + const latCenter = polygon.reduce((s, p) => s + p.lat, 0) / n + const cosLat = Math.cos(latCenter * DEG2RAD) + let area = 0 + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const x1 = polygon[i].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000 + const y1 = polygon[i].lat * EARTH_RADIUS * DEG2RAD / 1000 + const x2 = polygon[j].lon * cosLat * EARTH_RADIUS * DEG2RAD / 1000 + const y2 = polygon[j].lat * EARTH_RADIUS * DEG2RAD / 1000 + area += x1 * y2 - x2 * y1 + } + return Math.abs(area) / 2 +} + +/** 원 면적 (km²) */ +export function circleAreaKm2(radiusM: number): number { + return Math.PI * (radiusM / 1000) ** 2 +} + /** 차단 시뮬레이션 실행 */ export function runContainmentAnalysis( trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>, diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 10ed147..b5b09d6 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -17,7 +17,7 @@ import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, Predic import { useSimulationStatus } from '../hooks/useSimulationStatus' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import { api } from '@common/services/api' -import { generateAIBoomLines } from '@common/utils/geo' +import { generateAIBoomLines, haversineDistance, pointInPolygon, polygonAreaKm2, circleAreaKm2 } from '@common/utils/geo' import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' @@ -175,6 +175,16 @@ export function OilSpillView() { const [simulationSummary, setSimulationSummary] = useState(null) const { data: simStatus } = useSimulationStatus(currentExecSn) + // 오염분석 상태 + const [analysisTab, setAnalysisTab] = useState<'polygon' | 'circle'>('polygon') + const [drawAnalysisMode, setDrawAnalysisMode] = useState<'polygon' | null>(null) + const [analysisPolygonPoints, setAnalysisPolygonPoints] = useState<{ lat: number; lon: number }[]>([]) + const [circleRadiusNm, setCircleRadiusNm] = useState(5) + const [analysisResult, setAnalysisResult] = useState<{ area: number; particleCount: number; particlePercent: number; sensitiveCount: number } | null>(null) + + // 원 분석용 derived 값 (state 아님) + const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null + const analysisCircleRadiusM = circleRadiusNm * 1852 const handleToggleLayer = (layerId: string, enabled: boolean) => { setEnabledLayers(prev => { @@ -465,8 +475,7 @@ export function OilSpillView() { setCenterPoints(cp ?? []) setWindData(wd ?? []) setHydrData(hd ?? []) - const booms = generateAIBoomLines(trajectory, coord, algorithmSettings) - setBoomLines(booms) + if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { @@ -482,10 +491,9 @@ export function OilSpillView() { } // 데모 궤적 생성 (fallback) - const demoTrajectory = generateDemoTrajectory(coord, demoModels, parseInt(analysis.duration) || 48) + const demoTrajectory = generateDemoTrajectory(coord ?? { lat: 37.39, lon: 126.64 }, demoModels, parseInt(analysis.duration) || 48) setOilTrajectory(demoTrajectory) - const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) - setBoomLines(demoBooms) + if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings)) setSensitiveResources(DEMO_SENSITIVE_RESOURCES) // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생 if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) { @@ -498,12 +506,65 @@ export function OilSpillView() { const handleMapClick = (lon: number, lat: number) => { if (isDrawingBoom) { setDrawingPoints(prev => [...prev, { lat, lon }]) + } else if (drawAnalysisMode === 'polygon') { + setAnalysisPolygonPoints(prev => [...prev, { lat, lon }]) } else { setIncidentCoord({ lon, lat }) setIsSelectingLocation(false) } } + const handleStartPolygonDraw = () => { + setDrawAnalysisMode('polygon') + setAnalysisPolygonPoints([]) + setAnalysisResult(null) + } + + const handleRunPolygonAnalysis = () => { + if (analysisPolygonPoints.length < 3) return + const currentParticles = oilTrajectory.filter(p => p.time === currentStep) + const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 + const inside = currentParticles.filter(p => pointInPolygon({ lat: p.lat, lon: p.lon }, analysisPolygonPoints)).length + const sensitiveCount = sensitiveResources.filter(r => pointInPolygon({ lat: r.lat, lon: r.lon }, analysisPolygonPoints)).length + setAnalysisResult({ + area: polygonAreaKm2(analysisPolygonPoints), + particleCount: inside, + particlePercent: Math.round((inside / totalIds) * 100), + sensitiveCount, + }) + setDrawAnalysisMode(null) + } + + const handleRunCircleAnalysis = () => { + if (!incidentCoord) return + const radiusM = circleRadiusNm * 1852 + const currentParticles = oilTrajectory.filter(p => p.time === currentStep) + const totalIds = new Set(oilTrajectory.map(p => p.particle ?? 0)).size || 1 + const inside = currentParticles.filter(p => + haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: p.lat, lon: p.lon }) <= radiusM + ).length + const sensitiveCount = sensitiveResources.filter(r => + haversineDistance({ lat: incidentCoord.lat, lon: incidentCoord.lon }, { lat: r.lat, lon: r.lon }) <= radiusM + ).length + setAnalysisResult({ + area: circleAreaKm2(radiusM), + particleCount: inside, + particlePercent: Math.round((inside / totalIds) * 100), + sensitiveCount, + }) + } + + const handleCancelAnalysis = () => { + setDrawAnalysisMode(null) + setAnalysisPolygonPoints([]) + } + + const handleClearAnalysis = () => { + setDrawAnalysisMode(null) + setAnalysisPolygonPoints([]) + setAnalysisResult(null) + } + const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => { setIncidentCoord({ lat: result.lat, lon: result.lon }) setFlyToCoord({ lat: result.lat, lon: result.lon }) @@ -723,7 +784,7 @@ export function OilSpillView() { enabledLayers={enabledLayers} incidentCoord={incidentCoord ?? undefined} flyToIncident={flyToCoord} - isSelectingLocation={isSelectingLocation || isDrawingBoom} + isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'} onMapClick={handleMapClick} oilTrajectory={oilTrajectory} selectedModels={selectedModels} @@ -739,6 +800,10 @@ export function OilSpillView() { flyToTarget={flyToTarget} fitBoundsTarget={fitBoundsTarget} onIncidentFlyEnd={handleFlyEnd} + drawAnalysisMode={drawAnalysisMode} + analysisPolygonPoints={analysisPolygonPoints} + analysisCircleCenter={analysisCircleCenter} + analysisCircleRadiusM={analysisCircleRadiusM} externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined} backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? { isActive: true, @@ -932,7 +997,28 @@ export function OilSpillView() { {/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />} + {activeSubTab === 'analysis' && ( + setRecalcModalOpen(true)} + onOpenReport={handleOpenReport} + detail={analysisDetail} + summary={simulationSummary} + analysisTab={analysisTab} + onSwitchAnalysisTab={setAnalysisTab} + drawAnalysisMode={drawAnalysisMode} + analysisPolygonPoints={analysisPolygonPoints} + circleRadiusNm={circleRadiusNm} + onCircleRadiusChange={setCircleRadiusNm} + analysisResult={analysisResult} + incidentCoord={incidentCoord} + onStartPolygonDraw={handleStartPolygonDraw} + onRunPolygonAnalysis={handleRunPolygonAnalysis} + onRunCircleAnalysis={handleRunCircleAnalysis} + onCancelAnalysis={handleCancelAnalysis} + onClearAnalysis={handleClearAnalysis} + /> + )} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index ac1709b..d854091 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -1,7 +1,43 @@ import { useState } from 'react' import type { PredictionDetail, SimulationSummary } from '../services/predictionApi' -export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null }) { +interface AnalysisResult { + area: number + particleCount: number + particlePercent: number + sensitiveCount: number +} + +interface RightPanelProps { + onOpenBacktrack?: () => void + onOpenRecalc?: () => void + onOpenReport?: () => void + detail?: PredictionDetail | null + summary?: SimulationSummary | null + analysisTab?: 'polygon' | 'circle' + onSwitchAnalysisTab?: (tab: 'polygon' | 'circle') => void + drawAnalysisMode?: 'polygon' | null + analysisPolygonPoints?: Array<{ lat: number; lon: number }> + circleRadiusNm?: number + onCircleRadiusChange?: (nm: number) => void + analysisResult?: AnalysisResult | null + incidentCoord?: { lat: number; lon: number } | null + onStartPolygonDraw?: () => void + onRunPolygonAnalysis?: () => void + onRunCircleAnalysis?: () => void + onCancelAnalysis?: () => void + onClearAnalysis?: () => void +} + +export function RightPanel({ + onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, + analysisTab = 'polygon', onSwitchAnalysisTab, + drawAnalysisMode, analysisPolygonPoints = [], + circleRadiusNm = 5, onCircleRadiusChange, + analysisResult, incidentCoord, + onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis, + onCancelAnalysis, onClearAnalysis, +}: RightPanelProps) { const vessel = detail?.vessels?.[0] const vessel2 = detail?.vessels?.[1] const spill = detail?.spill @@ -35,9 +71,116 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail {/* 오염분석 */}
- + {/* 탭 전환 */} +
+ {(['polygon', 'circle'] as const).map((tab) => ( + + ))} +
+ + {/* 다각형 패널 */} + {analysisTab === 'polygon' && ( +
+

+ 지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다. +

+ {!drawAnalysisMode && !analysisResult && ( + + )} + {drawAnalysisMode === 'polygon' && ( +
+
+ 지도를 클릭하여 꼭짓점을 추가하세요
+ 현재 {analysisPolygonPoints.length}개 선택됨 +
+
+ + +
+
+ )} + {analysisResult && !drawAnalysisMode && ( + + )} +
+ )} + + {/* 원 분석 패널 */} + {analysisTab === 'circle' && ( +
+

+ 반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다. +

+
반경 선택 (NM)
+
+ {[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => ( + + ))} +
+
+ 직접 입력 + onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)} + className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan" + style={{ colorScheme: 'dark' }} + /> + NM + +
+ {analysisResult && ( + + )} +
+ )}
{/* 오염 종합 상황 */} @@ -226,8 +369,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str {children} @@ -425,3 +567,78 @@ function InsuranceCard({ ) } + +function PollResult({ + result, + summary, + onClear, + onRerun, + radiusNm, +}: { + result: AnalysisResult + summary?: SimulationSummary | null + onClear?: () => void + onRerun?: () => void + radiusNm?: number +}) { + const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2) + return ( +
+
+ {radiusNm && ( +
+ 분석 결과 + 반경 {radiusNm} NM +
+ )} +
+
+
{result.area.toFixed(2)}
+
분석면적(km²)
+
+
+
{result.particlePercent}%
+
오염비율
+
+
+
{pollutedArea}
+
오염면적(km²)
+
+
+
+ {summary && ( +
+ 해상잔존량 + {summary.remainingVolume.toFixed(2)} kL +
+ )} + {summary && ( +
+ 연안부착량 + {summary.beachedVolume.toFixed(2)} kL +
+ )} +
+ 민감자원 포함 + {result.sensitiveCount}개소 +
+
+
+ + {onRerun && ( + + )} +
+
+ ) +}