feat(prediction): 오염분석 다각형/원 분석 기능 구현
- 오염분석 섹션을 탭 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 <noreply@anthropic.com>
This commit is contained in:
부모
bf41763925
커밋
dc82574635
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
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 { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification } from 'maplibre-gl'
|
import type { StyleSpecification } from 'maplibre-gl'
|
||||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
@ -189,6 +189,10 @@ interface MapViewProps {
|
|||||||
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
|
||||||
onIncidentFlyEnd?: () => void
|
onIncidentFlyEnd?: () => void
|
||||||
flyToIncident?: { lon: number; lat: number }
|
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)
|
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
|
||||||
@ -311,6 +315,10 @@ export function MapView({
|
|||||||
mapCaptureRef,
|
mapCaptureRef,
|
||||||
onIncidentFlyEnd,
|
onIncidentFlyEnd,
|
||||||
flyToIncident,
|
flyToIncident,
|
||||||
|
drawAnalysisMode = null,
|
||||||
|
analysisPolygonPoints = [],
|
||||||
|
analysisCircleCenter,
|
||||||
|
analysisCircleRadiusM = 0,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const { mapToggles } = useMapStore()
|
const { mapToggles } = useMapStore()
|
||||||
const isControlled = externalCurrentTime !== undefined
|
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, 고정 이미지) ---
|
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
|
||||||
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
|
||||||
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
|
||||||
@ -829,6 +922,7 @@ export function MapView({
|
|||||||
boomLines, isDrawingBoom, drawingPoints,
|
boomLines, isDrawingBoom, drawingPoints,
|
||||||
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
|
||||||
sensitiveResources, centerPoints, windData,
|
sensitiveResources, centerPoints, windData,
|
||||||
|
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
|
||||||
])
|
])
|
||||||
|
|
||||||
// 3D 모드에 따른 지도 스타일 전환
|
// 3D 모드에 따른 지도 스타일 전환
|
||||||
@ -844,7 +938,7 @@ export function MapView({
|
|||||||
}}
|
}}
|
||||||
mapStyle={currentMapStyle}
|
mapStyle={currentMapStyle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null) ? 'crosshair' : 'grab' }}
|
||||||
onClick={handleMapClick}
|
onClick={handleMapClick}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
preserveDrawingBuffer={true}
|
preserveDrawingBuffer={true}
|
||||||
@ -928,6 +1022,16 @@ export function MapView({
|
|||||||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{drawAnalysisMode === 'polygon' && (
|
||||||
|
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||||
|
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{drawAnalysisMode === 'circle' && (
|
||||||
|
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||||||
|
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 기상청 연계 정보 */}
|
{/* 기상청 연계 정보 */}
|
||||||
<WeatherInfoPanel position={currentPosition} />
|
<WeatherInfoPanel position={currentPosition} />
|
||||||
|
|||||||
@ -186,6 +186,47 @@ export function generateAIBoomLines(
|
|||||||
return boomLines
|
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(
|
export function runContainmentAnalysis(
|
||||||
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, Predic
|
|||||||
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
import { useSimulationStatus } from '../hooks/useSimulationStatus'
|
||||||
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
|
||||||
import { api } from '@common/services/api'
|
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'
|
import { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal'
|
||||||
|
|
||||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||||
@ -175,6 +175,16 @@ export function OilSpillView() {
|
|||||||
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(null)
|
||||||
const { data: simStatus } = useSimulationStatus(currentExecSn)
|
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<number>(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) => {
|
const handleToggleLayer = (layerId: string, enabled: boolean) => {
|
||||||
setEnabledLayers(prev => {
|
setEnabledLayers(prev => {
|
||||||
@ -465,8 +475,7 @@ export function OilSpillView() {
|
|||||||
setCenterPoints(cp ?? [])
|
setCenterPoints(cp ?? [])
|
||||||
setWindData(wd ?? [])
|
setWindData(wd ?? [])
|
||||||
setHydrData(hd ?? [])
|
setHydrData(hd ?? [])
|
||||||
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
|
if (coord) setBoomLines(generateAIBoomLines(trajectory, coord, algorithmSettings))
|
||||||
setBoomLines(booms)
|
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
@ -482,10 +491,9 @@ export function OilSpillView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 데모 궤적 생성 (fallback)
|
// 데모 궤적 생성 (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)
|
setOilTrajectory(demoTrajectory)
|
||||||
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
|
if (coord) setBoomLines(generateAIBoomLines(demoTrajectory, coord, algorithmSettings))
|
||||||
setBoomLines(demoBooms)
|
|
||||||
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
|
||||||
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
|
||||||
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
|
||||||
@ -498,12 +506,65 @@ export function OilSpillView() {
|
|||||||
const handleMapClick = (lon: number, lat: number) => {
|
const handleMapClick = (lon: number, lat: number) => {
|
||||||
if (isDrawingBoom) {
|
if (isDrawingBoom) {
|
||||||
setDrawingPoints(prev => [...prev, { lat, lon }])
|
setDrawingPoints(prev => [...prev, { lat, lon }])
|
||||||
|
} else if (drawAnalysisMode === 'polygon') {
|
||||||
|
setAnalysisPolygonPoints(prev => [...prev, { lat, lon }])
|
||||||
} else {
|
} else {
|
||||||
setIncidentCoord({ lon, lat })
|
setIncidentCoord({ lon, lat })
|
||||||
setIsSelectingLocation(false)
|
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) => {
|
const handleImageAnalysisResult = useCallback((result: ImageAnalyzeResult) => {
|
||||||
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
setIncidentCoord({ lat: result.lat, lon: result.lon })
|
||||||
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
setFlyToCoord({ lat: result.lat, lon: result.lon })
|
||||||
@ -723,7 +784,7 @@ export function OilSpillView() {
|
|||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
flyToIncident={flyToCoord}
|
flyToIncident={flyToCoord}
|
||||||
isSelectingLocation={isSelectingLocation || isDrawingBoom}
|
isSelectingLocation={isSelectingLocation || isDrawingBoom || drawAnalysisMode === 'polygon'}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={oilTrajectory}
|
oilTrajectory={oilTrajectory}
|
||||||
selectedModels={selectedModels}
|
selectedModels={selectedModels}
|
||||||
@ -739,6 +800,10 @@ export function OilSpillView() {
|
|||||||
flyToTarget={flyToTarget}
|
flyToTarget={flyToTarget}
|
||||||
fitBoundsTarget={fitBoundsTarget}
|
fitBoundsTarget={fitBoundsTarget}
|
||||||
onIncidentFlyEnd={handleFlyEnd}
|
onIncidentFlyEnd={handleFlyEnd}
|
||||||
|
drawAnalysisMode={drawAnalysisMode}
|
||||||
|
analysisPolygonPoints={analysisPolygonPoints}
|
||||||
|
analysisCircleCenter={analysisCircleCenter}
|
||||||
|
analysisCircleRadiusM={analysisCircleRadiusM}
|
||||||
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
|
||||||
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@ -932,7 +997,28 @@ export function OilSpillView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel */}
|
{/* Right Panel */}
|
||||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
|
{activeSubTab === 'analysis' && (
|
||||||
|
<RightPanel
|
||||||
|
onOpenBacktrack={handleOpenBacktrack}
|
||||||
|
onOpenRecalc={() => 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 && (
|
{isRunningSimulation && (
|
||||||
|
|||||||
@ -1,7 +1,43 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
|
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 vessel = detail?.vessels?.[0]
|
||||||
const vessel2 = detail?.vessels?.[1]
|
const vessel2 = detail?.vessels?.[1]
|
||||||
const spill = detail?.spill
|
const spill = detail?.spill
|
||||||
@ -35,9 +71,116 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
|
|||||||
|
|
||||||
{/* 오염분석 */}
|
{/* 오염분석 */}
|
||||||
<Section title="오염분석">
|
<Section title="오염분석">
|
||||||
<button className="w-full py-2 px-3 bg-gradient-to-r from-purple-500 to-primary-cyan text-white rounded text-[10px] font-bold font-korean">
|
{/* 탭 전환 */}
|
||||||
📐 다각형 분석수행
|
<div className="flex gap-[3px] mb-2">
|
||||||
</button>
|
{(['polygon', 'circle'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => { onSwitchAnalysisTab?.(tab); onClearAnalysis?.() }}
|
||||||
|
className={`flex-1 py-1.5 px-1 rounded text-[9px] font-semibold font-korean border transition-colors ${
|
||||||
|
analysisTab === tab
|
||||||
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)] text-primary-cyan'
|
||||||
|
: 'border-border bg-bg-3 text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'polygon' ? '다각형 분석' : '원 분석'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다각형 패널 */}
|
||||||
|
{analysisTab === 'polygon' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||||
|
지도에서 다각형 영역을 지정하여 해당 범위 내 오염도를 분석합니다.
|
||||||
|
</p>
|
||||||
|
{!drawAnalysisMode && !analysisResult && (
|
||||||
|
<button
|
||||||
|
onClick={onStartPolygonDraw}
|
||||||
|
className="w-full py-2 rounded text-[10px] font-bold font-korean text-white mb-0 transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||||
|
>
|
||||||
|
📐 다각형 분석수행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{drawAnalysisMode === 'polygon' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-[9px] text-purple-400 font-korean bg-[rgba(168,85,247,0.08)] rounded px-2 py-1.5 leading-relaxed">
|
||||||
|
지도를 클릭하여 꼭짓점을 추가하세요<br />
|
||||||
|
<span className="text-text-3">현재 {analysisPolygonPoints.length}개 선택됨</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onRunPolygonAnalysis}
|
||||||
|
disabled={analysisPolygonPoints.length < 3}
|
||||||
|
className="flex-1 py-1.5 rounded text-[10px] font-bold font-korean text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--purple), var(--cyan))' }}
|
||||||
|
>
|
||||||
|
분석 실행
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelAnalysis}
|
||||||
|
className="py-1.5 px-2 rounded text-[10px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{analysisResult && !drawAnalysisMode && (
|
||||||
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} onRerun={onStartPolygonDraw} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 원 분석 패널 */}
|
||||||
|
{analysisTab === 'circle' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] text-text-3 font-korean mb-2 leading-relaxed">
|
||||||
|
반경(NM)을 지정하면 사고지점 기준 원형 영역의 오염도를 분석합니다.
|
||||||
|
</p>
|
||||||
|
<div className="text-[9px] font-semibold text-text-2 font-korean mb-1.5">반경 선택 (NM)</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => (
|
||||||
|
<button
|
||||||
|
key={nm}
|
||||||
|
onClick={() => onCircleRadiusChange?.(nm)}
|
||||||
|
className={`w-8 h-7 rounded text-[10px] font-semibold font-mono border transition-all ${
|
||||||
|
circleRadiusNm === nm
|
||||||
|
? 'border-primary-cyan bg-[rgba(6,182,212,0.1)] text-primary-cyan'
|
||||||
|
: 'border-border bg-bg-0 text-text-3 hover:text-text-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nm}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-2.5">
|
||||||
|
<span className="text-[9px] text-text-3 font-korean whitespace-nowrap">직접 입력</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={circleRadiusNm}
|
||||||
|
onChange={(e) => 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' }}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-text-3 font-korean">NM</span>
|
||||||
|
<button
|
||||||
|
onClick={onRunCircleAnalysis}
|
||||||
|
className="ml-auto py-1 px-3 rounded text-[9px] font-bold font-korean text-white transition-opacity hover:opacity-90"
|
||||||
|
style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}
|
||||||
|
>
|
||||||
|
분석 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{analysisResult && (
|
||||||
|
<PollResult result={analysisResult} summary={summary} onClear={onClearAnalysis} radiusNm={circleRadiusNm} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 오염 종합 상황 */}
|
{/* 오염 종합 상황 */}
|
||||||
@ -226,8 +369,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked={checked}
|
defaultChecked={checked}
|
||||||
className="w-[13px] h-[13px]"
|
className="w-[13px] h-[13px] accent-[var(--cyan)]"
|
||||||
className="accent-[var(--cyan)]"
|
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
@ -425,3 +567,78 @@ function InsuranceCard({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-1 p-2.5 bg-bg-0 border border-[rgba(168,85,247,0.2)] rounded-md" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, var(--purple), var(--cyan))' }} />
|
||||||
|
{radiusNm && (
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-[10px] font-semibold text-text-1 font-korean">분석 결과</span>
|
||||||
|
<span className="text-[9px] font-semibold text-primary-cyan font-mono">반경 {radiusNm} NM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-1 mb-2">
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--red)' }}>{result.area.toFixed(2)}</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">분석면적(km²)</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--orange)' }}>{result.particlePercent}%</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염비율</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-1.5 px-1 bg-bg-3 rounded">
|
||||||
|
<div className="text-[13px] font-bold font-mono" style={{ color: 'var(--cyan)' }}>{pollutedArea}</div>
|
||||||
|
<div className="text-[7px] text-text-3 font-korean mt-0.5">오염면적(km²)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-[9px] font-korean">
|
||||||
|
{summary && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">해상잔존량</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--blue)' }}>{summary.remainingVolume.toFixed(2)} kL</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">연안부착량</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--red)' }}>{summary.beachedVolume.toFixed(2)} kL</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-3">민감자원 포함</span>
|
||||||
|
<span className="font-semibold font-mono" style={{ color: 'var(--orange)' }}>{result.sensitiveCount}개소</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-border text-text-3 hover:text-text-2 transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
{onRerun && (
|
||||||
|
<button
|
||||||
|
onClick={onRerun}
|
||||||
|
className="flex-1 py-1.5 rounded text-[9px] font-semibold font-korean border border-[rgba(168,85,247,0.3)] text-purple-400 hover:bg-[rgba(168,85,247,0.08)] transition-colors"
|
||||||
|
>
|
||||||
|
재분석
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user