Merge pull request 'feat(prediction): 오염분석 다각형/원 분석 기능 구현' (#87) from feature/prediction-pollution-analysis into develop

This commit is contained in:
jhkang 2026-03-13 14:48:46 +09:00
커밋 1bfc06c6c5
8개의 변경된 파일496개의 추가작업 그리고 33개의 파일을 삭제

파일 보기

@ -5,29 +5,29 @@
},
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(curl -s *)",
"Bash(fnm *)",
"Bash(git add *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git diff *)",
"Bash(git fetch *)",
"Bash(git log *)",
"Bash(git merge *)",
"Bash(git pull *)",
"Bash(git remote *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git stash *)",
"Bash(git status)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
"Bash(node *)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(npx *)"
],
"deny": [
"Bash(git push --force*)",
@ -83,5 +83,7 @@
]
}
]
}
},
"deny": [],
"allow": []
}

파일 보기

@ -1,6 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-11",
"applied_date": "2026-03-13",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true

파일 보기

@ -5,12 +5,19 @@
## [Unreleased]
### 추가
- 오염분석 다각형/원 분석 기능 구현
- 시뮬레이션 에러 모달 추가
- 해류 캔버스 파티클 레이어 추가
### 수정
- useSubMenu useEffect import 누락 수정
### 변경
- 보고서 해안부착 현황 개선
### 기타
- 팀 워크플로우 동기화 (v1.6.1)
## [2026-03-11.2]
### 추가

파일 보기

@ -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'
@ -194,6 +194,10 @@ interface MapViewProps {
showBeached?: boolean
showTimeLabel?: boolean
simulationStartTime?: string
drawAnalysisMode?: 'polygon' | 'circle' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -321,6 +325,10 @@ export function MapView({
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@ -541,6 +549,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));
@ -873,6 +966,7 @@ export function MapView({
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM,
])
// 3D 모드에 따른 지도 스타일 전환
@ -888,7 +982,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}
@ -972,6 +1066,16 @@ export function MapView({
({drawingPoints.length} )
</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} />

파일 보기

@ -1,4 +1,4 @@
import { useSyncExternalStore } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import type { MainTab } from '../types/navigation'
import { useAuthStore } from '@common/store/authStore'
import { API_BASE_URL } from '@common/services/api'

파일 보기

@ -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 }>,

파일 보기

@ -18,7 +18,7 @@ import { useSimulationStatus } from '../hooks/useSimulationStatus'
import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import SimulationErrorModal from './SimulationErrorModal'
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'
@ -192,6 +192,16 @@ export function OilSpillView() {
const [simulationSummary, setSimulationSummary] = useState<SimulationSummary | null>(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<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) => {
setEnabledLayers(prev => {
@ -483,8 +493,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) {
@ -500,10 +509,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) {
@ -516,12 +524,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 })
@ -744,7 +805,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}
@ -760,6 +821,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,
@ -958,7 +1023,30 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />}
{activeSubTab === 'analysis' && (
<RightPanel
onOpenBacktrack={handleOpenBacktrack}
onOpenRecalc={() => setRecalcModalOpen(true)}
onOpenReport={handleOpenReport}
detail={analysisDetail}
summary={simulationSummary}
displayControls={displayControls}
onDisplayControlsChange={setDisplayControls}
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 && (

파일 보기

@ -2,7 +2,46 @@ import { useState } from 'react'
import type { PredictionDetail, SimulationSummary } from '../services/predictionApi'
import type { DisplayControls } from './OilSpillView'
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail, summary, displayControls, onDisplayControlsChange }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null; summary?: SimulationSummary | null; displayControls?: DisplayControls; onDisplayControlsChange?: (controls: DisplayControls) => void }) {
interface AnalysisResult {
area: number
particleCount: number
particlePercent: number
sensitiveCount: number
}
interface RightPanelProps {
onOpenBacktrack?: () => void
onOpenRecalc?: () => void
onOpenReport?: () => void
detail?: PredictionDetail | null
summary?: SimulationSummary | null
displayControls?: DisplayControls
onDisplayControlsChange?: (controls: DisplayControls) => void
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,
displayControls, onDisplayControlsChange,
analysisTab = 'polygon', onSwitchAnalysisTab,
drawAnalysisMode, analysisPolygonPoints = [],
circleRadiusNm = 5, onCircleRadiusChange,
analysisResult,
onStartPolygonDraw, onRunPolygonAnalysis, onRunCircleAnalysis,
onCancelAnalysis, onClearAnalysis,
}: RightPanelProps) {
const vessel = detail?.vessels?.[0]
const vessel2 = detail?.vessels?.[1]
const spill = detail?.spill
@ -49,9 +88,116 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail
{/* 오염분석 */}
<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">
📐
</button>
{/* 탭 전환 */}
<div className="flex gap-[3px] mb-2">
{(['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>
{/* 오염 종합 상황 */}
@ -454,3 +600,78 @@ function InsuranceCard({
</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>
)
}