diff --git a/.claude/settings.json b/.claude/settings.json
index 908a71e..16aa8a0 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -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*)",
@@ -84,4 +84,4 @@
}
]
}
-}
\ No newline at end of file
+}
diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json
index f9f4b86..faa5b68 100644
--- a/.claude/workflow-version.json
+++ b/.claude/workflow-version.json
@@ -1,7 +1,7 @@
{
"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
-}
\ No newline at end of file
+}
diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md
index 8ff6c7f..24342fc 100644
--- a/docs/RELEASE-NOTES.md
+++ b/docs/RELEASE-NOTES.md
@@ -6,6 +6,7 @@
### 추가
- 시뮬레이션 에러 모달 추가
+- 해류 캔버스 파티클 레이어 추가
### 변경
- 보고서 해안부착 현황 개선
diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx
index 7ebebe0..3be27d9 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'
@@ -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}개 포인트)
)}
+ {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 41f2ba9..5455aac 100755
--- a/frontend/src/tabs/prediction/components/OilSpillView.tsx
+++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx
@@ -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(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 => {
@@ -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() {
{/* Right Panel */}
- {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />}
+ {activeSubTab === 'analysis' && (
+ 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 && (
diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx
index 64dfdbd..4246ab2 100755
--- a/frontend/src/tabs/prediction/components/RightPanel.tsx
+++ b/frontend/src/tabs/prediction/components/RightPanel.tsx
@@ -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
{/* 오염분석 */}
-
+ {/* 탭 전환 */}
+
+ {(['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 && (
+
+ )}
+
+ )}
{/* 오염 종합 상황 */}
@@ -454,3 +600,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 && (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx
new file mode 100644
index 0000000..db18f7c
--- /dev/null
+++ b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx
@@ -0,0 +1,324 @@
+import { useEffect, useRef, useCallback } from 'react'
+import { useMap } from '@vis.gl/react-maplibre'
+import type { Map as MapLibreMap } from 'maplibre-gl'
+
+interface CurrentVectorPoint {
+ lat: number
+ lon: number
+ u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s]
+ v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s]
+}
+
+interface Particle {
+ x: number
+ y: number
+ age: number
+ maxAge: number
+}
+
+interface OceanCurrentParticleLayerProps {
+ visible: boolean
+}
+
+// 해류 속도 기반 색상
+function getCurrentColor(speed: number): string {
+ if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑
+ if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록
+ if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록
+ return 'rgba(249, 115, 22, 0.8)' // 주황
+}
+
+// 한반도 육지 영역 판별 (간략화된 폴리곤)
+const isOnLand = (lat: number, lon: number): boolean => {
+ const peninsula: [number, number][] = [
+ [38.5, 124.5], [38.5, 128.3],
+ [37.8, 128.8], [37.0, 129.2],
+ [36.0, 129.5], [35.1, 129.2],
+ [34.8, 128.6], [34.5, 127.8],
+ [34.3, 126.5], [34.8, 126.1],
+ [35.5, 126.0], [36.0, 126.3],
+ [36.8, 126.0], [37.5, 126.2],
+ [38.5, 124.5],
+ ]
+
+ // 제주도 영역
+ if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true
+
+ // Ray casting algorithm
+ let inside = false
+ for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) {
+ const [yi, xi] = peninsula[i]
+ const [yj, xj] = peninsula[j]
+ if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
+ inside = !inside
+ }
+ }
+ return inside
+}
+
+// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock)
+const generateOceanCurrentData = (): CurrentVectorPoint[] => {
+ const data: CurrentVectorPoint[] = []
+
+ for (let lat = 33.5; lat <= 38.0; lat += 0.8) {
+ for (let lon = 125.0; lon <= 130.5; lon += 0.8) {
+ if (isOnLand(lat, lon)) continue
+
+ let u = 0
+ let v = 0
+
+ if (lon > 128.5) {
+ // 동해 — 북동진하는 동한난류
+ u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4
+ v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5
+ } else if (lon < 126.5) {
+ // 서해 — 북진
+ u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05
+ v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3
+ } else {
+ // 남해 — 동진
+ u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5
+ v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1
+ }
+
+ data.push({ lat, lon, u, v })
+ }
+ }
+
+ return data
+}
+
+// 해류 데이터는 한 번만 생성
+const CURRENT_DATA = generateOceanCurrentData()
+
+// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환
+function interpolateCurrent(
+ lat: number,
+ lon: number,
+ points: CurrentVectorPoint[]
+): { speed: number; direction: number } {
+ if (points.length === 0) return { speed: 0.3, direction: 90 }
+
+ let totalWeight = 0
+ let weightedU = 0
+ let weightedV = 0
+
+ for (const point of points) {
+ const dist = Math.sqrt(
+ Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2)
+ )
+ const weight = 1 / Math.pow(Math.max(dist, 0.01), 2)
+ totalWeight += weight
+ weightedU += point.u * weight
+ weightedV += point.v * weight
+ }
+
+ const u = weightedU / totalWeight
+ const v = weightedV / totalWeight
+ const speed = Math.sqrt(u * u + v * v)
+ // u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y)
+ const direction = (Math.atan2(u, v) * 180) / Math.PI
+ return { speed, direction: (direction + 360) % 360 }
+}
+
+// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환
+function containerPointToLatLng(
+ map: MapLibreMap,
+ x: number,
+ y: number
+): { lat: number; lng: number } {
+ const lngLat = map.unproject([x, y])
+ return { lat: lngLat.lat, lng: lngLat.lng }
+}
+
+const PARTICLE_COUNT = 400
+const FADE_ALPHA = 0.93
+
+/**
+ * OceanCurrentParticleLayer
+ *
+ * Canvas 2D + requestAnimationFrame 패턴으로 해류 흐름 시각화
+ * u,v 벡터 격자 데이터를 IDW 보간하여 파티클 애니메이션 렌더링
+ * 바람 파티클 대비: 적은 입자, 느린 속도, 긴 트레일
+ */
+export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) {
+ const { current: mapRef } = useMap()
+ const canvasRef = useRef(null)
+ const particlesRef = useRef([])
+ const animFrameRef = useRef(0)
+
+ const initParticles = useCallback((width: number, height: number) => {
+ particlesRef.current = []
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ particlesRef.current.push({
+ x: Math.random() * width,
+ y: Math.random() * height,
+ age: Math.floor(Math.random() * 150),
+ maxAge: 120 + Math.floor(Math.random() * 60),
+ })
+ }
+ }, [])
+
+ useEffect(() => {
+ const map = mapRef?.getMap()
+ if (!map) return
+
+ if (!visible) {
+ if (canvasRef.current) {
+ canvasRef.current.remove()
+ canvasRef.current = null
+ }
+ cancelAnimationFrame(animFrameRef.current)
+ return
+ }
+
+ const container = map.getContainer()
+
+ // Canvas 생성 또는 재사용
+ let canvas = canvasRef.current
+ if (!canvas) {
+ canvas = document.createElement('canvas')
+ canvas.style.position = 'absolute'
+ canvas.style.top = '0'
+ canvas.style.left = '0'
+ canvas.style.pointerEvents = 'none'
+ canvas.style.zIndex = '440'
+ container.appendChild(canvas)
+ canvasRef.current = canvas
+ }
+
+ const resize = () => {
+ if (!canvas) return
+ const { clientWidth: w, clientHeight: h } = container
+ canvas.width = w
+ canvas.height = h
+ }
+ resize()
+
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return
+
+ initParticles(canvas.width, canvas.height)
+
+ // 오프스크린 캔버스 (트레일 효과)
+ let offCanvas: HTMLCanvasElement | null = null
+ let offCtx: CanvasRenderingContext2D | null = null
+
+ function animate() {
+ if (!ctx || !canvas) return
+
+ // 오프스크린 캔버스 크기 동기화
+ if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) {
+ offCanvas = document.createElement('canvas')
+ offCanvas.width = canvas.width
+ offCanvas.height = canvas.height
+ offCtx = offCanvas.getContext('2d')
+ }
+
+ if (!offCtx) return
+
+ // 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름)
+ offCtx.globalCompositeOperation = 'destination-in'
+ offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})`
+ offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height)
+ offCtx.globalCompositeOperation = 'source-over'
+
+ // 현재 지도 bounds 확인
+ const bounds = map!.getBounds()
+
+ for (const particle of particlesRef.current) {
+ particle.age++
+
+ // 수명 초과 시 리셋
+ if (particle.age > particle.maxAge) {
+ particle.x = Math.random() * canvas.width
+ particle.y = Math.random() * canvas.height
+ particle.age = 0
+ particle.maxAge = 120 + Math.floor(Math.random() * 60)
+ continue
+ }
+
+ const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y)
+
+ // 화면 밖이면 리셋
+ if (!bounds.contains([lng, lat])) {
+ particle.x = Math.random() * canvas.width
+ particle.y = Math.random() * canvas.height
+ particle.age = 0
+ continue
+ }
+
+ // 육지 위이면 리셋
+ if (isOnLand(lat, lng)) {
+ particle.x = Math.random() * canvas.width
+ particle.y = Math.random() * canvas.height
+ particle.age = 0
+ continue
+ }
+
+ const current = interpolateCurrent(lat, lng, CURRENT_DATA)
+ const rad = (current.direction * Math.PI) / 180
+ const pixelSpeed = current.speed * 2.0
+
+ const newX = particle.x + Math.sin(rad) * pixelSpeed
+ const newY = particle.y + -Math.cos(rad) * pixelSpeed
+
+ // 다음 위치가 육지이면 리셋
+ const nextPos = containerPointToLatLng(map!, newX, newY)
+ if (isOnLand(nextPos.lat, nextPos.lng)) {
+ particle.x = Math.random() * canvas.width
+ particle.y = Math.random() * canvas.height
+ particle.age = 0
+ continue
+ }
+
+ const oldX = particle.x
+ const oldY = particle.y
+ particle.x = newX
+ particle.y = newY
+
+ // 파티클 트레일 그리기
+ const alpha = 1 - particle.age / particle.maxAge
+ offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8))
+ offCtx.lineWidth = 0.8
+ offCtx.beginPath()
+ offCtx.moveTo(oldX, oldY)
+ offCtx.lineTo(particle.x, particle.y)
+ offCtx.stroke()
+ }
+
+ // 메인 캔버스에 합성 (배경 오버레이 없이 파티클만)
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+ ctx.drawImage(offCanvas, 0, 0)
+
+ animFrameRef.current = requestAnimationFrame(animate)
+ }
+
+ animate()
+
+ // 지도 이동/줌 시 리셋
+ const onMoveEnd = () => {
+ resize()
+ if (canvas) initParticles(canvas.width, canvas.height)
+ if (offCanvas && canvas) {
+ offCanvas.width = canvas.width
+ offCanvas.height = canvas.height
+ }
+ }
+ map.on('moveend', onMoveEnd)
+ map.on('zoomend', onMoveEnd)
+
+ return () => {
+ cancelAnimationFrame(animFrameRef.current)
+ map.off('moveend', onMoveEnd)
+ map.off('zoomend', onMoveEnd)
+ if (canvasRef.current) {
+ canvasRef.current.remove()
+ canvasRef.current = null
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mapRef, visible, initParticles])
+
+ return null
+}
diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx
index 04fe62e..35414db 100755
--- a/frontend/src/tabs/weather/components/WeatherView.tsx
+++ b/frontend/src/tabs/weather/components/WeatherView.tsx
@@ -6,12 +6,13 @@ import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { WeatherRightPanel } from './WeatherRightPanel'
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
-import { OceanForecastOverlay } from './OceanForecastOverlay'
-import { useOceanCurrentLayers } from './OceanCurrentLayer'
+// import { OceanForecastOverlay } from './OceanForecastOverlay'
+// import { useOceanCurrentLayers } from './OceanCurrentLayer'
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
import { WindParticleLayer } from './WindParticleLayer'
+import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
import { useWeatherData } from '../hooks/useWeatherData'
-import { useOceanForecast } from '../hooks/useOceanForecast'
+// import { useOceanForecast } from '../hooks/useOceanForecast'
import { WeatherMapControls } from './WeatherMapControls'
type TimeOffset = '0' | '3' | '6' | '9'
@@ -125,8 +126,6 @@ interface WeatherMapInnerProps {
weatherStations: WeatherStation[]
enabledLayers: Set
selectedStationId: string | null
- oceanForecastOpacity: number
- selectedForecast: ReturnType['selectedForecast']
onStationClick: (station: WeatherStation) => void
mapCenter: [number, number]
mapZoom: number
@@ -137,8 +136,6 @@ function WeatherMapInner({
weatherStations,
enabledLayers,
selectedStationId,
- oceanForecastOpacity,
- selectedForecast,
onStationClick,
mapCenter,
mapZoom,
@@ -151,18 +148,18 @@ function WeatherMapInner({
selectedStationId,
onStationClick
)
- const oceanCurrentLayers = useOceanCurrentLayers({
- visible: enabledLayers.has('oceanCurrent'),
- opacity: 0.7,
- })
+ // const oceanCurrentLayers = useOceanCurrentLayers({
+ // visible: enabledLayers.has('oceanCurrent'),
+ // opacity: 0.7,
+ // })
const waterTempLayers = useWaterTemperatureLayers({
visible: enabledLayers.has('waterTemperature'),
opacity: 0.5,
})
const deckLayers = useMemo(
- () => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers],
- [oceanCurrentLayers, waterTempLayers, weatherDeckLayers]
+ () => [...waterTempLayers, ...weatherDeckLayers],
+ [waterTempLayers, weatherDeckLayers]
)
return (
@@ -170,11 +167,16 @@ function WeatherMapInner({
{/* deck.gl 오버레이 */}
- {/* 해황예보도 — MapLibre image source + raster layer */}
+ {/* 해황예보도 — 임시 비활성화
*/}
+
+ {/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
+
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
@@ -224,13 +226,13 @@ export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
- const {
- selectedForecast,
- availableTimes,
- loading: oceanLoading,
- error: oceanError,
- selectForecast,
- } = useOceanForecast('KOREA')
+ // const {
+ // selectedForecast,
+ // availableTimes,
+ // loading: oceanLoading,
+ // error: oceanError,
+ // selectForecast,
+ // } = useOceanForecast('KOREA')
const [timeOffset, setTimeOffset] = useState('0')
const [selectedStationRaw, setSelectedStation] = useState(null)
@@ -238,7 +240,7 @@ export function WeatherView() {
null
)
const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind']))
- const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
+ // const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값)
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
@@ -361,8 +363,6 @@ export function WeatherView() {
weatherStations={weatherStations}
enabledLayers={enabledLayers}
selectedStationId={selectedStation?.id || null}
- oceanForecastOpacity={oceanForecastOpacity}
- selectedForecast={selectedForecast}
onStationClick={handleStationClick}
mapCenter={WEATHER_MAP_CENTER}
mapZoom={WEATHER_MAP_ZOOM}
@@ -424,11 +424,11 @@ export function WeatherView() {
- {/* 해황예보도 레이어 */}
+ {/* 해황예보도 레이어 — 임시 비활성화
-
- {enabledLayers.has('oceanForecast') && (
-
-
- 투명도:
-
- setOceanForecastOpacity(Number(e.target.value) / 100)
- }
- className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer"
- />
-
- {Math.round(oceanForecastOpacity * 100)}%
-
-
-
- {availableTimes.length > 0 && (
-
-
예보 시간:
-
- {availableTimes.map((time) => (
-
- ))}
-
-
- )}
-
- {oceanLoading &&
로딩 중...
}
- {oceanError &&
오류 발생
}
- {selectedForecast && (
-
- 현재: {selectedForecast.ofcBrnchNm} •{' '}
- {selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
- {selectedForecast.ofcFrcstTm}:00
-
- )}
-
- )}
+ */}
@@ -536,6 +484,23 @@ export function WeatherView() {
+ {/* 해류 */}
+
+
해류 (m/s)
+
+
+ 0.2
+ 0.4
+ 0.6
+ 0.6+
+
+
+
{/* 파고 */}