From 0c94c631c41e8399b1e7d71e744363daeacc50b5 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 12 Mar 2026 11:03:17 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(weather):=20=ED=95=B4=EB=A5=98=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=ED=8C=8C=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../components/OceanCurrentParticleLayer.tsx | 324 ++++++++++++++++++ .../tabs/weather/components/WeatherView.tsx | 127 +++---- 2 files changed, 370 insertions(+), 81 deletions(-) create mode 100644 frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx 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+ +
+
+ {/* 파고 */}
파고 (m)
-- 2.45.2 From 61ac3b42c0de2aa3b8f1f78b3a789da07202d733 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 12 Mar 2026 16:47:06 +0900 Subject: [PATCH 02/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a6ab4a4..973c91e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 해류 캔버스 파티클 레이어 추가 + ## [2026-03-11.2] ### 추가 -- 2.45.2 From 9ddae7a973ff6c60b3edcc84419d20fbfdd28527 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Thu, 12 Mar 2026 19:08:25 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(prediction):=20=EC=8B=9C=EB=AE=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=97=90=EB=9F=AC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=20=ED=95=B4=EC=95=88=EB=B6=80=EC=B0=A9=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/prediction/predictionService.ts | 3 + backend/src/routes/simulation.ts | 67 ++++++++--- frontend/package-lock.json | 7 ++ frontend/package.json | 1 + .../src/common/components/layout/TopBar.tsx | 19 +++ .../src/common/components/map/MapView.tsx | 54 ++++++++- frontend/src/common/hooks/useSubMenu.ts | 13 +-- .../aerial/components/OilAreaAnalysis.tsx | 71 +++++++++-- .../tabs/prediction/components/LeftPanel.tsx | 102 ++++++++++------ .../prediction/components/OilSpillView.tsx | 42 ++++++- .../components/PredictionInputSection.tsx | 17 ++- .../tabs/prediction/components/RightPanel.tsx | 55 ++++++--- .../components/SimulationErrorModal.tsx | 110 ++++++++++++++++++ .../tabs/prediction/services/predictionApi.ts | 1 + .../reports/components/ReportGenerator.tsx | 27 +++-- 15 files changed, 485 insertions(+), 104 deletions(-) create mode 100644 frontend/src/tabs/prediction/components/SimulationErrorModal.tsx diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index bdf86a5..0769ea7 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -18,6 +18,7 @@ interface PredictionAnalysis { backtrackStatus: string; analyst: string; officeName: string; + acdntSttsCd: string; } interface PredictionDetail { @@ -129,6 +130,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise = { 'kL': 'KL', 'ton': 'TON', 'barrel': 'BBL', } +// ============================================================ +// 신규 생성된 ACDNT/SPIL_DATA/PRED_EXEC 롤백 헬퍼 +// Python 호출 실패 시 이번 요청에서 생성된 레코드만 삭제한다. +// ============================================================ +async function rollbackNewRecords( + predExecSn: number | null, + newSpilDataSn: number | null, + newAcdntSn: number | null +): Promise { + try { + if (predExecSn !== null) { + await wingPool.query('DELETE FROM wing.PRED_EXEC WHERE PRED_EXEC_SN=$1', [predExecSn]) + } + if (newSpilDataSn !== null) { + await wingPool.query('DELETE FROM wing.SPIL_DATA WHERE SPIL_DATA_SN=$1', [newSpilDataSn]) + } + if (newAcdntSn !== null) { + await wingPool.query('DELETE FROM wing.ACDNT WHERE ACDNT_SN=$1', [newAcdntSn]) + } + } catch (cleanupErr) { + console.error('[simulation] 롤백 실패:', cleanupErr) + } +} + // ============================================================ // POST /api/simulation/run // 확산 시뮬레이션 실행 (OpenDrift) @@ -92,9 +116,30 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { return res.status(400).json({ error: '사고명은 200자 이내여야 합니다.' }) } + // 2. Python NC 파일 존재 여부 확인 (ACDNT 생성 전에 수행하여 고아 레코드 방지) + try { + const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon, startTime }), + signal: AbortSignal.timeout(5000), + }) + if (!checkRes.ok) { + return res.status(409).json({ + error: '해당 좌표의 해양 기상 데이터가 없습니다.', + message: 'NC 파일이 준비되지 않았습니다.', + }) + } + } catch { + // Python 서버 미기동 — 5번에서 처리 + } + // 1-B. acdntSn 미제공 시 ACDNT + SPIL_DATA 생성 let resolvedAcdntSn: number | null = rawAcdntSn ? Number(rawAcdntSn) : null let resolvedSpilDataSn: number | null = null + // 이번 요청에서 신규 생성된 레코드 추적 (Python 실패 시 롤백 대상) + let newlyCreatedAcdntSn: number | null = null + let newlyCreatedSpilDataSn: number | null = null if (!resolvedAcdntSn && acdntNm) { try { @@ -116,6 +161,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { [acdntNm.trim(), occrn, lat, lon] ) resolvedAcdntSn = acdntRes.rows[0].acdnt_sn as number + newlyCreatedAcdntSn = resolvedAcdntSn const spilRes = await wingPool.query( `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) @@ -131,30 +177,13 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { ] ) resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number + newlyCreatedSpilDataSn = resolvedSpilDataSn } catch (dbErr) { console.error('[simulation] ACDNT/SPIL_DATA INSERT 실패:', dbErr) return res.status(500).json({ error: '사고 정보 생성 실패' }) } } - // 2. Python NC 파일 존재 여부 확인 - try { - const checkRes = await fetch(`${PYTHON_API_URL}/check-nc`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat, lon, startTime }), - signal: AbortSignal.timeout(5000), - }) - if (!checkRes.ok) { - return res.status(409).json({ - error: '해당 좌표의 해양 기상 데이터가 없습니다.', - message: 'NC 파일이 준비되지 않았습니다.', - }) - } - } catch { - // Python 서버 미기동 — 5번에서 처리 - } - // 3. 기존 사고의 경우 SPIL_DATA_SN 조회 if (resolvedAcdntSn && !resolvedSpilDataSn) { try { @@ -215,6 +244,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG=$1, CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$2`, [errData.error || '분석 서버 포화', predExecSn] ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) return res.status(503).json({ error: errData.error || '분석 서버가 사용 중입니다. 잠시 후 재시도해 주세요.' }) } @@ -229,6 +259,7 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { `UPDATE wing.PRED_EXEC SET EXEC_STTS_CD='FAILED', ERR_MSG='Python 분석 서버에 연결할 수 없습니다.', CMPL_DTM=NOW() WHERE PRED_EXEC_SN=$1`, [predExecSn] ) + await rollbackNewRecords(predExecSn, newlyCreatedSpilDataSn, newlyCreatedAcdntSn) return res.status(503).json({ error: 'Python 분석 서버에 연결할 수 없습니다.' }) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e2e1d6..d2534ec 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "exifr": "^7.1.3", "hls.js": "^1.6.15", "jszip": "^3.10.1", "lucide-react": "^0.564.0", @@ -3848,6 +3849,12 @@ "node": ">=0.10.0" } }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 325dc86..14c3f0d 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", + "exifr": "^7.1.3", "hls.js": "^1.6.15", "jszip": "^3.10.1", "lucide-react": "^0.564.0", diff --git a/frontend/src/common/components/layout/TopBar.tsx b/frontend/src/common/components/layout/TopBar.tsx index 5695c75..a1db12e 100755 --- a/frontend/src/common/components/layout/TopBar.tsx +++ b/frontend/src/common/components/layout/TopBar.tsx @@ -73,6 +73,25 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { ) })} + + {/* 실시간 상황관리 */} +
diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 1e7b7e2..7ebebe0 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -189,6 +189,11 @@ interface MapViewProps { mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> onIncidentFlyEnd?: () => void flyToIncident?: { lon: number; lat: number } + showCurrent?: boolean + showWind?: boolean + showBeached?: boolean + showTimeLabel?: boolean + simulationStartTime?: string } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -311,6 +316,11 @@ export function MapView({ mapCaptureRef, onIncidentFlyEnd, flyToIncident, + showCurrent = true, + showWind = true, + showBeached = false, + showTimeLabel = false, + simulationStartTime, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -393,8 +403,10 @@ export function MapView({ getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], getRadius: 3, getFillColor: (d: (typeof visibleParticles)[0]) => { - // 1순위: stranded 입자 → 빨간색 - if (d.stranded === 1) return [239, 68, 68, 220] as [number, number, number, number] + // 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색 + if (d.stranded === 1) return showBeached + ? [239, 68, 68, 220] as [number, number, number, number] + : [130, 130, 130, 70] as [number, number, number, number] // 2순위: 현재 활성 스텝 → 모델 기본 색상 if (d.time === activeStep) { const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' @@ -427,7 +439,7 @@ export function MapView({ } }, updateTriggers: { - getFillColor: [selectedModels, currentTime], + getFillColor: [selectedModels, currentTime, showBeached], }, }) ) @@ -782,8 +794,39 @@ export function MapView({ ) } + // --- 시간 표시 라벨 (TextLayer) --- + if (visibleCenters.length > 0 && showTimeLabel) { + const baseTime = simulationStartTime ? new Date(simulationStartTime) : null; + const pad = (n: number) => String(n).padStart(2, '0'); + result.push( + new TextLayer({ + id: 'time-labels', + data: visibleCenters, + getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], + getText: (d: (typeof visibleCenters)[0]) => { + if (baseTime) { + const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000); + return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; + } + return `+${d.time}h`; + }, + getSize: 12, + getColor: [255, 220, 50, 220] as [number, number, number, number], + getPixelOffset: [0, 16] as [number, number], + fontWeight: 'bold', + outlineWidth: 2, + outlineColor: [15, 21, 36, 200] as [number, number, number, number], + billboard: true, + sizeUnits: 'pixels' as const, + updateTriggers: { + getText: [simulationStartTime, currentTime], + }, + }) + ) + } + // --- 바람 화살표 (TextLayer) --- - if (incidentCoord && windData.length > 0) { + if (incidentCoord && windData.length > 0 && showWind) { type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number } const activeWindStep = windData[currentTime] ?? windData[0] ?? [] @@ -829,6 +872,7 @@ export function MapView({ boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, centerPoints, windData, + showWind, showBeached, showTimeLabel, simulationStartTime, ]) // 3D 모드에 따른 지도 스타일 전환 @@ -887,7 +931,7 @@ export function MapView({ {/* 해류 파티클 오버레이 */} - {hydrData.length > 0 && ( + {hydrData.length > 0 && showCurrent && ( )} diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 34e8ed6..121d221 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useSyncExternalStore } from 'react' import type { MainTab } from '../types/navigation' import { useAuthStore } from '@common/store/authStore' import { API_BASE_URL } from '@common/services/api' @@ -38,7 +38,7 @@ const subMenuConfigs: Record = { ], aerial: [ { id: 'media', label: '영상사진관리', icon: '📷' }, - { id: 'analysis', label: '유출유면적분석', icon: '🧩' }, + { id: 'analysis', label: '영상사진합성', icon: '🧩' }, { id: 'realtime', label: '실시간드론', icon: '🛸' }, { id: 'sensor', label: '오염/선박3D분석', icon: '🔍' }, { id: 'satellite', label: '위성요청', icon: '🛰' }, @@ -91,17 +91,10 @@ function subscribe(listener: () => void) { } export function useSubMenu(mainTab: MainTab) { - const [activeSubTab, setActiveSubTabLocal] = useState(subMenuState[mainTab]) + const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab]) const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const hasPermission = useAuthStore((s) => s.hasPermission) - useEffect(() => { - const unsubscribe = subscribe(() => { - setActiveSubTabLocal(subMenuState[mainTab]) - }) - return unsubscribe - }, [mainTab]) - const setActiveSubTab = (subTab: string) => { setSubTab(mainTab, subTab) } diff --git a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx index 47c0bc2..7567dd7 100644 --- a/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx +++ b/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx @@ -1,20 +1,29 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import * as exifr from 'exifr'; import { stitchImages } from '../services/aerialApi'; import { analyzeImage } from '@tabs/prediction/services/predictionApi'; import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal'; import { navigateToTab } from '@common/hooks/useSubMenu'; +import { decimalToDMS } from '@common/utils/coordinates'; const MAX_IMAGES = 6; +interface GpsInfo { + lat: number | null; + lon: number | null; +} + export function OilAreaAnalysis() { const [selectedFiles, setSelectedFiles] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); + const [imageGpsInfos, setImageGpsInfos] = useState<(GpsInfo | undefined)[]>([]); const [stitchedBlob, setStitchedBlob] = useState(null); const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState(null); const [isStitching, setIsStitching] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); + const processedFilesRef = useRef>(new Set()); // Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke useEffect(() => { @@ -25,6 +34,34 @@ export function OilAreaAnalysis() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // 선택된 파일이 바뀔 때마다 새 파일의 EXIF GPS 추출 + useEffect(() => { + selectedFiles.forEach((file, i) => { + if (processedFilesRef.current.has(file)) return; + processedFilesRef.current.add(file); + + exifr.gps(file) + .then(gps => { + setImageGpsInfos(prev => { + const updated = [...prev]; + while (updated.length <= i) updated.push(undefined); + updated[i] = gps + ? { lat: gps.latitude, lon: gps.longitude } + : { lat: null, lon: null }; + return updated; + }); + }) + .catch(() => { + setImageGpsInfos(prev => { + const updated = [...prev]; + while (updated.length <= i) updated.push(undefined); + updated[i] = { lat: null, lon: null }; + return updated; + }); + }); + }); + }, [selectedFiles]); + const handleFileSelect = useCallback((e: React.ChangeEvent) => { setError(null); const incoming = Array.from(e.target.files ?? []); @@ -56,6 +93,7 @@ export function OilAreaAnalysis() { URL.revokeObjectURL(prev[idx]); return prev.filter((_, i) => i !== idx); }); + setImageGpsInfos(prev => prev.filter((_, i) => i !== idx)); // 합성 결과 초기화 (선택 파일이 바뀌었으므로) setStitchedBlob(null); if (stitchedPreviewUrl) { @@ -112,7 +150,7 @@ export function OilAreaAnalysis() {
{/* ── Left Panel ── */}
-
🧩 유출유면적분석
+
🧩 영상사진합성
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
@@ -201,15 +239,34 @@ export function OilAreaAnalysis() { {Array.from({ length: MAX_IMAGES }).map((_, i) => (
{previewUrls[i] ? ( - {selectedFiles[i]?.name + <> +
+ {selectedFiles[i]?.name +
+
+
+ {selectedFiles[i]?.name} +
+ {imageGpsInfos[i] === undefined ? ( +
GPS 읽는 중...
+ ) : imageGpsInfos[i]?.lat !== null ? ( +
+ {decimalToDMS(imageGpsInfos[i]!.lat!, true)}
+ {decimalToDMS(imageGpsInfos[i]!.lon!, false)} +
+ ) : ( +
GPS 정보 없음
+ )} +
+ ) : (
{i + 1} diff --git a/frontend/src/tabs/prediction/components/LeftPanel.tsx b/frontend/src/tabs/prediction/components/LeftPanel.tsx index 40260b7..cc5b1fc 100755 --- a/frontend/src/tabs/prediction/components/LeftPanel.tsx +++ b/frontend/src/tabs/prediction/components/LeftPanel.tsx @@ -53,7 +53,7 @@ export function LeftPanel({ predictionInput: true, incident: false, impactResources: false, - infoLayer: true, + infoLayer: false, oilBoom: false, }) @@ -112,45 +112,73 @@ export function LeftPanel({
{expandedSections.incident && ( -
- {/* Status Badge */} -
- - 진행중 -
+ selectedAnalysis ? ( +
+ {/* Status Badge */} + {(() => { + const statusMap: Record = { + ACTIVE: { + label: '진행중', + style: 'bg-[rgba(239,68,68,0.15)] text-status-red border border-[rgba(239,68,68,0.3)]', + dot: 'bg-status-red animate-pulse', + }, + INVESTIGATING: { + label: '조사중', + style: 'bg-[rgba(249,115,22,0.15)] text-status-orange border border-[rgba(249,115,22,0.3)]', + dot: 'bg-status-orange animate-pulse', + }, + CLOSED: { + label: '종료', + style: 'bg-[rgba(100,116,139,0.15)] text-text-3 border border-[rgba(100,116,139,0.3)]', + dot: 'bg-text-3', + }, + } + const s = statusMap[selectedAnalysis.acdntSttsCd] ?? statusMap['ACTIVE'] + return ( +
+ + {s.label} +
+ ) + })()} - {/* Info Grid */} -
-
- 사고코드 - {selectedAnalysis ? `INC-2025-${String(selectedAnalysis.id).padStart(4, '0')}` : 'INC-2025-0042'} -
-
- 사고명 - {selectedAnalysis?.name || '씨프린스호'} -
-
- 사고일시 - {selectedAnalysis?.occurredAt || '2025-02-10 06:30'} -
-
- 유종 - {selectedAnalysis?.oilType || 'BUNKER_C'} -
-
- 유출량 - {selectedAnalysis ? `${selectedAnalysis.volume.toFixed(2)} kl` : '350.00 kl'} -
-
- 담당자 - {selectedAnalysis?.analyst || '남해청, 방재과'} -
-
- 위치 - {selectedAnalysis?.location || '여수 돌산 남방 5NM'} + {/* Info Grid */} +
+
+ 사고코드 + {selectedAnalysis.acdntSn} +
+
+ 사고명 + {selectedAnalysis.acdntNm || '—'} +
+
+ 사고일시 + {selectedAnalysis.occurredAt ? selectedAnalysis.occurredAt.slice(0, 16) : '—'} +
+
+ 유종 + {selectedAnalysis.oilType || '—'} +
+
+ 유출량 + {selectedAnalysis.volume != null ? `${selectedAnalysis.volume.toFixed(2)} kl` : '—'} +
+
+ 담당자 + {selectedAnalysis.analyst || '—'} +
+
+ 위치 + {selectedAnalysis.location || '—'} +
-
+ ) : ( +
+

선택된 사고정보가 없습니다.

+
+ ) )}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 10ed147..41f2ba9 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -16,6 +16,7 @@ import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAna import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' 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 { consumePendingImageAnalysis } from '@common/utils/imageAnalysisSignal' @@ -35,6 +36,13 @@ export interface SensitiveResource { arrivalTimeH: number } +export interface DisplayControls { + showCurrent: boolean; // 유향/유속 + showWind: boolean; // 풍향/풍속 + showBeached: boolean; // 해안부착 + showTimeLabel: boolean; // 시간 표시 +} + const DEMO_SENSITIVE_RESOURCES: SensitiveResource[] = [ { id: 'bc-1', name: '종포 해수욕장', type: 'beach', lat: 34.728, lon: 127.679, radiusM: 350, arrivalTimeH: 1 }, { id: 'aq-1', name: '국동 전복 양식장', type: 'aquaculture', lat: 34.718, lon: 127.672, radiusM: 500, arrivalTimeH: 3 }, @@ -114,7 +122,8 @@ export function OilSpillView() { const [windData, setWindData] = useState([]) const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) const [isRunningSimulation, setIsRunningSimulation] = useState(false) - const [selectedModels, setSelectedModels] = useState>(new Set(['KOSPS'])) + const [simulationError, setSimulationError] = useState(null) + const [selectedModels, setSelectedModels] = useState>(new Set(['OpenDrift'])) const [predictionTime, setPredictionTime] = useState(48) const [accidentTime, setAccidentTime] = useState('') const [spillType, setSpillType] = useState('연속') @@ -142,6 +151,14 @@ export function OilSpillView() { const [layerOpacity, setLayerOpacity] = useState(50) const [layerBrightness, setLayerBrightness] = useState(50) + // 표시 정보 제어 + const [displayControls, setDisplayControls] = useState({ + showCurrent: true, + showWind: true, + showBeached: false, + showTimeLabel: false, + }) + // 타임라인 플레이어 상태 const [isPlaying, setIsPlaying] = useState(false) const [currentStep, setCurrentStep] = useState(0) // 현재 시간값 (시간 단위) @@ -373,6 +390,7 @@ export function OilSpillView() { if (simStatus.status === 'ERROR') { setIsRunningSimulation(false); setCurrentExecSn(null); + setSimulationError(simStatus.error ?? '시뮬레이션 처리 중 오류가 발생했습니다.'); } }, [simStatus, incidentCoord, algorithmSettings]); @@ -598,9 +616,12 @@ export function OilSpillView() { setIncidentName(''); } // setIsRunningSimulation(false)는 폴링 결과 useEffect에서 처리 - } catch { + } catch (err) { setIsRunningSimulation(false); - // 503 등 에러 시 상태 복원 (에러 메시지 표시는 향후 토스트로 처리) + const msg = + (err as { message?: string })?.message + ?? '시뮬레이션 실행 중 오류가 발생했습니다.'; + setSimulationError(msg); } } @@ -748,6 +769,11 @@ export function OilSpillView() { totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, } : undefined} + showCurrent={displayControls.showCurrent} + showWind={displayControls.showWind} + showBeached={displayControls.showBeached} + showTimeLabel={displayControls.showTimeLabel} + simulationStartTime={accidentTime || undefined} /> {/* 타임라인 플레이어 (리플레이 비활성 시) */} @@ -932,7 +958,7 @@ 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} displayControls={displayControls} onDisplayControlsChange={setDisplayControls} />} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( @@ -942,6 +968,14 @@ export function OilSpillView() { /> )} + {/* 확산 예측 에러 팝업 */} + {simulationError && ( + setSimulationError(null)} + /> + )} + {/* 재계산 모달 */} {/* Model Selection (다중 선택) */} + {/* TODO: 현재 OpenDrift만 구동 가능. KOSPS·POSEIDON·앙상블은 엔진 연동 완료 후 활성화 예정 */}
+ {/* 임시 비활성화 — OpenDrift만 구동 가능 (KOSPS 엔진 미연동) + { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, */} + {/* 임시 비활성화 — OpenDrift만 구동 가능 (POSEIDON 엔진 미연동) + { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, */} {([ - { id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' }, - { id: 'POSEIDON' as PredictionModel, color: 'var(--red)' }, { id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' }, ] as const).map(m => (
))} + {/* 임시 비활성화 — OpenDrift만 구동 가능 (앙상블은 모든 모델 연동 후 활성화 예정)
{ @@ -415,8 +418,16 @@ const PredictionInputSection = ({ 앙상블
+ */}
+ {/* 모델 미선택 경고 */} + {selectedModels.size === 0 && ( +

+ ⚠ 예측 모델을 하나 이상 선택하세요. +

+ )} + {/* Run Button */} +
+
+ ); +}; + +export default SimulationErrorModal; diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 308d24d..0b3994e 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -18,6 +18,7 @@ export interface PredictionAnalysis { backtrackStatus: string; analyst: string; officeName: string; + acdntSttsCd: string; } export interface PredictionDetail { diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index 9e780fd..c478d59 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -343,13 +343,26 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)} - {sec.id === 'oil-coastal' && ( -

- 최초 부착시간: {oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime} - {' / '} - 부착 해안길이: {oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength} -

- )} + {sec.id === 'oil-coastal' && (() => { + const coastLength = oilPayload?.pollution.coastLength; + const hasNoCoastal = oilPayload && ( + !coastLength || coastLength === '—' || coastLength.startsWith('0.00') + ); + if (hasNoCoastal) { + return ( +

+ 시뮬레이션 결과 유출유의 해안 부착이 없습니다. +

+ ); + } + return ( +

+ 최초 부착시간: {oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime} + {' / '} + 부착 해안길이: {coastLength || sampleOilData.coastal.coastLength} +

+ ); + })()} {sec.id === 'oil-defense' && (

방제자원 배치 계획에 따른 전략을 수립합니다.

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

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

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

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

+
반경 선택 (NM)
+
+ {[1, 3, 5, 10, 15, 20, 30, 50].map((nm) => ( + + ))} +
+
+ 직접 입력 + onCircleRadiusChange?.(parseFloat(e.target.value) || 0.1)} + className="w-14 text-center py-1 px-1 bg-bg-0 border border-border rounded text-[11px] font-mono text-text-1 outline-none focus:border-primary-cyan" + style={{ colorScheme: 'dark' }} + /> + NM + +
+ {analysisResult && ( + + )} +
+ )}
{/* 오염 종합 상황 */} @@ -226,8 +369,7 @@ function CheckboxLabel({ checked, children }: { checked?: boolean; children: str {children} @@ -425,3 +567,78 @@ function InsuranceCard({
) } + +function PollResult({ + result, + summary, + onClear, + onRerun, + radiusNm, +}: { + result: AnalysisResult + summary?: SimulationSummary | null + onClear?: () => void + onRerun?: () => void + radiusNm?: number +}) { + const pollutedArea = (result.area * result.particlePercent / 100).toFixed(2) + return ( +
+
+ {radiusNm && ( +
+ 분석 결과 + 반경 {radiusNm} NM +
+ )} +
+
+
{result.area.toFixed(2)}
+
분석면적(km²)
+
+
+
{result.particlePercent}%
+
오염비율
+
+
+
{pollutedArea}
+
오염면적(km²)
+
+
+
+ {summary && ( +
+ 해상잔존량 + {summary.remainingVolume.toFixed(2)} kL +
+ )} + {summary && ( +
+ 연안부착량 + {summary.beachedVolume.toFixed(2)} kL +
+ )} +
+ 민감자원 포함 + {result.sensitiveCount}개소 +
+
+
+ + {onRerun && ( + + )} +
+
+ ) +} -- 2.45.2 From efc8f18bb9f46ee3909a2fb9dc1214f8a2a0e5ab Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Fri, 13 Mar 2026 09:26:21 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20(v1?= =?UTF-8?q?.6.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 34 +++++++++++++++++----------------- .claude/workflow-version.json | 4 ++-- 2 files changed, 19 insertions(+), 19 deletions(-) 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 +} -- 2.45.2 From 827dab27a0d5ef9a20222e2203a31c6a2492b106 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 13 Mar 2026 13:17:01 +0900 Subject: [PATCH 06/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a6ab4a4..8ff6c7f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- 시뮬레이션 에러 모달 추가 + +### 변경 +- 보고서 해안부착 현황 개선 + ## [2026-03-11.2] ### 추가 -- 2.45.2 From 6864f6dab547ef95fe898aaeb2d5f10f4643c988 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 13 Mar 2026 14:10:15 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix(useSubMenu):=20useEffect=20import=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/common/hooks/useSubMenu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 121d221..fb11da7 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -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' -- 2.45.2 From a40daf2263d726608d7d49ea3d1a5fce0fb53ed0 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 13 Mar 2026 14:14:57 +0900 Subject: [PATCH 08/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 24342fc..aa95c27 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -5,12 +5,19 @@ ## [Unreleased] ### 추가 +- 오염분석 다각형/원 분석 기능 구현 - 시뮬레이션 에러 모달 추가 - 해류 캔버스 파티클 레이어 추가 +### 수정 +- useSubMenu useEffect import 누락 수정 + ### 변경 - 보고서 해안부착 현황 개선 +### 기타 +- 팀 워크플로우 동기화 (v1.6.1) + ## [2026-03-11.2] ### 추가 -- 2.45.2 From d693c6865fe9e534a6dc4cd0c8dea1c860468d21 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 13 Mar 2026 14:30:43 +0900 Subject: [PATCH 09/10] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20(v1?= =?UTF-8?q?.6.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 6 ++++-- .claude/workflow-version.json | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 16aa8a0..3027e9b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,5 +83,7 @@ ] } ] - } -} + }, + "deny": [], + "allow": [] +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index faa5b68..ab9c219 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -4,4 +4,4 @@ "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file -- 2.45.2 From 4e0bb23dab8b04d504eea5508b6204c59921b07b Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 13 Mar 2026 14:56:05 +0900 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/RELEASE-NOTES.md | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index aa95c27..18d7385 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-13] + ### 추가 - 오염분석 다각형/원 분석 기능 구현 - 시뮬레이션 에러 모달 추가 @@ -18,25 +20,6 @@ ### 기타 - 팀 워크플로우 동기화 (v1.6.1) -## [2026-03-11.2] - -### 추가 -- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조) -- flyTo 완료 후 자동 재생 기능 -- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경) -- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합 -- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml) - -### 변경 -- 이미지 분석/보고서/항공 UI 개선 -- CCTV/관리자 고도화 - -### 기타 -- 팀 워크플로우 v1.6.1 적용일 갱신 -- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결) -- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리) -- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가) - ## [2026-03-11] ### 추가 @@ -47,18 +30,27 @@ - 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널 - CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거 - 유류오염보장계약 시드 데이터 추가 (1391건) +- OpenDrift 유류 확산 시뮬레이션 통합 (비동기 폴링 구조) +- flyTo 완료 후 자동 재생 기능 +- 이미지 분석 서버 Docker 패키징 (CPU 전용 환경) +- SPIL_DATA 이미지 분석 결과 컬럼 인라인 통합 +- CPU 전용 Docker 환경 구축 (Dockerfile.cpu, docker-compose.cpu.yml) ### 수정 - /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정 +### 변경 +- 이미지 분석/보고서/항공 UI 개선 +- CCTV/관리자 고도화 + ### 문서 - 프로젝트 문서 최신화 (KHOA API, Vite 프록시) ### 기타 - CLAUDE_BOT_TOKEN 갱신 -- 팀 워크플로우 v1.6.1 동기화 -- 팀 워크플로우 v1.6.0 동기화 -- 팀 워크플로우 v1.5.0 동기화 +- 팀 워크플로우 v1.6.1 동기화 (custom_pre_commit 프로젝트 해시 불일치 해결, 적용일 갱신) +- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리) +- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가) ## [2026-03-01] -- 2.45.2