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 && (
+
+ )}
+
+
+ )
+}