From e285f2330ff1638adcfb732e3b3162209eadc571 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 27 Mar 2026 14:57:00 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(prediction):=20=EC=97=AD=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EB=B6=84=EC=84=9D=20=EC=97=94=EC=A7=84=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8F=99=EC=A0=81=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=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 - 백엔드: backtrackAnalysisService 신규 개발 * AIS 기반 선박 항적 API 연동 및 공간 조회 * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정 * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성 * Python 서버 미연동 시 폴백 메커니즘 제공 - 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환 - 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능 - 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시 - DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스) --- .../prediction/backtrackAnalysisService.ts | 557 ++++++++++++++++++ backend/src/prediction/predictionService.ts | 19 +- database/migration/030_ais_track.sql | 19 + .../components/map/BacktrackReplayBar.tsx | 47 +- frontend/src/common/types/backtrack.ts | 13 + .../prediction/components/BacktrackModal.tsx | 132 ++++- .../prediction/components/OilSpillView.tsx | 66 +-- .../tabs/prediction/services/predictionApi.ts | 5 +- 8 files changed, 787 insertions(+), 71 deletions(-) create mode 100644 backend/src/prediction/backtrackAnalysisService.ts create mode 100644 database/migration/030_ais_track.sql diff --git a/backend/src/prediction/backtrackAnalysisService.ts b/backend/src/prediction/backtrackAnalysisService.ts new file mode 100644 index 0000000..dbeaf61 --- /dev/null +++ b/backend/src/prediction/backtrackAnalysisService.ts @@ -0,0 +1,557 @@ +import { wingPool } from '../db/wingDb.js'; +import { getBacktrack } from './predictionService.js'; + +const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'; +const VESSEL_TRACK_API_URL = process.env.VESSEL_TRACK_API_URL ?? 'http://localhost:9090'; + +// 유종 코드(DB) → OpenDrift 유종 코드 매핑 +const OIL_TYPE_MAP: Record = { + 'BUNKER_C': 'GENERIC BUNKER C', + 'DIESEL': 'GENERIC DIESEL', + 'CRUDE_OIL': 'WEST TEXAS INTERMEDIATE (WTI)', + 'HEAVY_FUEL_OIL': 'GENERIC HEAVY FUEL OIL', + 'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)', + 'GASOLINE': 'GENERIC GASOLINE', +}; +const POLL_INTERVAL_MS = 3000; +const POLL_TIMEOUT_MS = 30 * 60 * 1000; + +// AIS 선박유형 코드 → 위험도 점수 매핑 +// AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선 +const VESSEL_TYPE_SCORES: Array<[number, number, number]> = [ + [80, 89, 1.0], // 유조선 계열 + [70, 79, 0.5], // 화물선 계열 + [30, 39, 0.3], // 어선 +]; + +const RANK_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', + '#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b', '#6366f1']; + +interface PythonParticle { lat: number; lon: number } +interface PythonTimeStep { + particles: PythonParticle[]; + center_lat?: number; + center_lon?: number; + remaining_volume_m3: number; + weathered_volume_m3: number; + pollution_area_km2: number; + beached_volume_m3: number; + pollution_coast_length_m: number; +} + +// ============================================================ +// 선박 항적 API 타입 +// ============================================================ + +interface VesselTrackApiRequest { + startTime: string; + endTime: string; + mode: 'SEQUENTIAL'; + polygons: Array<{ + id: string; + name: string; + coordinates: [number, number][]; + }>; +} + +interface ChnPrmShipInfo { + imo: number; + name: string; + callsign: string; + vesselType: string; + lat: number; + lon: number; + sog: number; + cog: number; + heading: number; + length: number; + width: number; + draught: number; + destination: string; + status: string; + messageTimestamp: string; +} + +interface VesselTrack { + vesselId: string; + nationalCode: string; + geometry: [number, number][]; // [lon, lat][] + speeds: number[]; // knots + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; + shipName: string; + shipType: string; // vessel type code string (e.g. "74") + shipKindCode: string; + chnPrmShipInfo: ChnPrmShipInfo | null; + timestamps: string[]; // Unix timestamp strings +} + +interface HitDetail { + polygonId: string; + polygonName: string; + entryTimestamp: number; + exitTimestamp: number; + hitPointCount: number; + visitIndex: number; +} + +interface VesselTrackApiResponse { + tracks: VesselTrack[]; + hitDetails: Record; + summary: { + totalVessels: number; + totalPoints: number; + mode: string; + polygonIds: string[]; + processingTimeMs: number; + cachedDates: string[]; + totalCachedVessels: number; + }; +} + +// haversine 거리 계산 (NM) +function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 3440.065; // NM + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +// anlysRange 파싱: '12', '±12시간', '12h' 등 → 숫자 +function parseAnalysisHours(anlysRange: string): number { + const m = anlysRange.match(/(\d+)/); + return m ? parseInt(m[1], 10) : 12; +} + +// 시간 포맷: Date → 'HH:MM' 형식 (KST) +function toTimeLabel(d: Date): string { + const kst = new Date(d.getTime() + 9 * 3600000); + return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`; +} + +// Python 결과 폴링 (DONE까지 대기) +async function pollUntilDone(jobId: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + try { + const res = await fetch(`${PYTHON_API_URL}/status/${jobId}`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) continue; + const data = await res.json() as { status: string; result?: PythonTimeStep[]; error?: string }; + if (data.status === 'DONE' && data.result) return data.result; + if (data.status === 'ERROR') throw new Error(data.error ?? 'Python 분석 오류'); + } catch (e) { + if (e instanceof Error && e.message !== 'fetch failed') throw e; + } + } + throw new Error('역추적 분석 시간 초과 (30분)'); +} + +// 파티클 스텝별 탐색 영역 계산 +function computeParticleSteps( + rawResult: PythonTimeStep[], + spillTime: Date, + anlysHours: number, +): Array<{ stepIdx: number; atTime: Date; centroid: { lat: number; lon: number }; radiusNm: number }> { + const totalSteps = rawResult.length; + const msPerStep = anlysHours * 3600000 / Math.max(totalSteps - 1, 1); + + return rawResult.map((step, idx) => { + const atTime = new Date(spillTime.getTime() - idx * msPerStep); + const particles = step.particles.filter(p => p.lat != null && p.lon != null); + + let centroid: { lat: number; lon: number }; + let radiusNm: number; + + if (particles.length === 0) { + centroid = { lat: step.center_lat ?? 0, lon: step.center_lon ?? 0 }; + radiusNm = 5; + } else { + centroid = { + lat: particles.reduce((s, p) => s + p.lat, 0) / particles.length, + lon: particles.reduce((s, p) => s + p.lon, 0) / particles.length, + }; + const maxDist = Math.max(...particles.map(p => haversineNm(centroid.lat, centroid.lon, p.lat, p.lon))); + radiusNm = Math.max(maxDist * 1.2, 2); // 최소 2 NM + } + + return { stepIdx: idx, atTime, centroid, radiusNm }; + }); +} + +// 선박유형 점수 +function getVesselTypeScore(vesselTp: number | null): number { + if (vesselTp == null) return 0.3; + for (const [min, max, score] of VESSEL_TYPE_SCORES) { + if (vesselTp >= min && vesselTp <= max) return score; + } + return 0.2; +} + +// 급감속 감지: 속도 배열에서 50% 이상 감소 여부 → 0~1 +function detectSpeedDrop(speeds: number[]): number { + const valid = speeds.filter(s => s > 0); + if (valid.length < 2) return 0; + let maxDrop = 0; + for (let i = 1; i < valid.length; i++) { + const drop = (valid[i - 1] - valid[i]) / valid[i - 1]; + if (drop > maxDrop) maxDrop = drop; + } + return maxDrop > 0.5 ? Math.min(1, maxDrop) : 0; +} + +// AIS 단절 감지: 타임스탬프 배열에서 평균 간격의 3배 이상 gap 존재 여부 +function detectAisGapFromTimestamps(timestamps: string[]): boolean { + if (timestamps.length < 3) return false; + const sorted = timestamps.map(Number).sort((a, b) => a - b); + const avgInterval = (sorted[sorted.length - 1] - sorted[0]) / (sorted.length - 1); + for (let i = 1; i < sorted.length; i++) { + if (sorted[i] - sorted[i - 1] > avgInterval * 3) return true; + } + return false; +} + +function vesselTypeToLabel(tp: number | null): string { + if (tp == null) return '미분류'; + if (tp >= 80 && tp <= 89) return '유조선'; + if (tp >= 70 && tp <= 79) return '화물선'; + if (tp >= 30 && tp <= 39) return '어선'; + if (tp >= 60 && tp <= 69) return '여객선'; + if (tp >= 90 && tp <= 99) return '특수선'; + return `선박(${tp})`; +} + +interface RankedVessel { + rank: number; + name: string; + imo: string; + type: string; + flag: string; + flagCountry: string; + probability: number; + closestTime: string; + closestDistance: number; + speedChange: string; + aisStatus: string; + description: string; + color: string; + mmsi: string; + _rawScore: number; + _track: VesselTrack; + _minDistIdx: number; +} + +// 탐색 폴리곤 빌드 (사고위치 + 탐색반경 → 바운딩 박스) +function buildSearchPolygon( + lat: number, + lon: number, + radiusNm: number, +): { id: string; name: string; coordinates: [number, number][] } { + const latDelta = radiusNm / 60; + const lonDelta = radiusNm / (60 * Math.cos(lat * Math.PI / 180)); + return { + id: 'zone_0', + name: '역추적 탐색구역', + coordinates: [ + [lon - lonDelta, lat - latDelta], + [lon + lonDelta, lat - latDelta], + [lon + lonDelta, lat + latDelta], + [lon - lonDelta, lat + latDelta], + [lon - lonDelta, lat - latDelta], + ], + }; +} + +// 선박 항적 API 호출 +async function fetchVesselTracks( + spillLat: number, + spillLon: number, + srchRadiusNm: number, + startTime: Date, + endTime: Date, +): Promise { + const polygon = buildSearchPolygon(spillLat, spillLon, srchRadiusNm); + const toApiDatetime = (d: Date) => d.toISOString().substring(0, 19); + + const body: VesselTrackApiRequest = { + startTime: toApiDatetime(startTime), + endTime: toApiDatetime(endTime), + mode: 'SEQUENTIAL', + polygons: [polygon], + }; + + console.log(body); + + const res = await fetch(`${VESSEL_TRACK_API_URL}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`); + return res.json() as Promise; +} + +// 스코어링 + 순위 (선박 항적 API 응답 기반) +function scoreAndRankVesselsFromApi( + tracks: VesselTrack[], + hitDetails: Record, + spillLat: number, + spillLon: number, + srchRadiusNm: number, + anlysHours: number, +): RankedVessel[] { + const anlysWindowSec = anlysHours * 3600; + + const scored = tracks.map(track => { + // 1. spatialScore (40%): 사고 지점과의 최근접 거리 + let minDist = Infinity; + let minDistIdx = 0; + track.geometry.forEach(([lon, lat], idx) => { + const d = haversineNm(lat, lon, spillLat, spillLon); + if (d < minDist) { minDist = d; minDistIdx = idx; } + }); + const spatialScore = Math.max(0, 1 - minDist / srchRadiusNm); + + // 2. temporalScore (25%): 탐색구역 체류시간 / 분석 윈도우 + const hits = hitDetails[track.vesselId] ?? []; + const totalTimeInZoneSec = hits.reduce((sum, h) => sum + (h.exitTimestamp - h.entryTimestamp), 0); + const temporalScore = Math.min(1, totalTimeInZoneSec / anlysWindowSec); + + // 3. behaviorScore (20%): 급감속 + AIS 단절 + const speedDrop = detectSpeedDrop(track.speeds); + const aisGap = detectAisGapFromTimestamps(track.timestamps); + const behaviorScore = Math.min(1, speedDrop * 0.6 + (aisGap ? 0.4 : 0)); + + // 4. vesselTypeScore (15%): 선박 유형별 위험도 + const vesselTpRaw = parseInt(track.shipType ?? '', 10); + const vesselTp = isNaN(vesselTpRaw) ? null : vesselTpRaw; + const vesselTypeScore = getVesselTypeScore(vesselTp); + + const rawScore = 0.40 * spatialScore + 0.25 * temporalScore + 0.20 * behaviorScore + 0.15 * vesselTypeScore; + + const speedChangeLabel = speedDrop > 0.5 ? '급감속' : speedDrop > 0.2 ? '감속' : '정상'; + const aisStatusLabel = aisGap ? 'AIS단절' : '정상'; + + const closestTs = track.timestamps[minDistIdx]; + const closestDate = closestTs ? new Date(Number(closestTs) * 1000) : new Date(); + + const descParts: string[] = []; + if (speedDrop > 0.5) descParts.push(`${toTimeLabel(closestDate)} 급감속 감지`); + if (aisGap) descParts.push('AIS 신호 단절 구간 존재'); + if (minDist < 1) descParts.push(`최근접 ${minDist.toFixed(2)} NM 이내 통과`); + + const imo = track.chnPrmShipInfo?.imo?.toString() ?? ''; + const vesselName = track.shipName || track.chnPrmShipInfo?.name || `MMSI:${track.vesselId}`; + + return { + mmsi: track.vesselId, + imo, + name: vesselName, + type: vesselTypeToLabel(vesselTp), + flag: track.nationalCode ?? '', + flagCountry: track.nationalCode ?? '', + closestTime: toTimeLabel(closestDate), + closestDistance: Math.round(minDist * 100) / 100, + speedChange: speedChangeLabel, + aisStatus: aisStatusLabel, + description: descParts.join(' · '), + probability: 0, + rank: 0, + color: '', + _rawScore: rawScore, + _track: track, + _minDistIdx: minDistIdx, + }; + }); + + scored.sort((a, b) => b._rawScore - a._rawScore); + const top = scored.slice(0, 10); + const maxScore = top[0]?._rawScore ?? 1; + + return top.map((v, i) => ({ + ...v, + rank: i + 1, + probability: Math.round((v._rawScore / maxScore) * 95 * 10) / 10, + color: RANK_COLORS[i] ?? '#888888', + })); +} + +interface ReplayShip { + vesselName: string; + color: string; + path: Array<{ lat: number; lon: number }>; + speedLabels: string[]; +} + +// 리플레이용 선박 경로 빌드 (상위 5척, API geometry 직접 사용) +function buildReplayShipsFromApi(ranked: RankedVessel[]): ReplayShip[] { + return ranked.slice(0, 5).map(v => ({ + vesselName: v.name, + color: v.color, + path: v._track.geometry.map(([lon, lat]) => ({ lat, lon })), + speedLabels: v._track.speeds.map(s => `${s.toFixed(1)} kts`), + })); +} + +interface CollisionEvent { + position: { lat: number; lon: number }; + timeLabel: string; + progressPercent: number; +} + +// 최고 확률 선박의 최근접 지점 이벤트 +function findCollisionEventFromApi( + ranked: RankedVessel[], + startTime: Date, + endTime: Date, +): CollisionEvent | null { + const top = ranked[0]; + if (!top) return null; + + const idx = top._minDistIdx; + const ts = top._track.timestamps[idx]; + const pointDate = ts ? new Date(Number(ts) * 1000) : new Date(); + + const totalMs = endTime.getTime() - startTime.getTime(); + const pointMs = pointDate.getTime() - startTime.getTime(); + const progressPercent = totalMs > 0 + ? Math.max(0, Math.min(100, Math.round((pointMs / totalMs) * 100))) + : 0; + + const point = top._track.geometry[idx]; + const [lon, lat] = point ?? [0, 0]; + + return { + position: { lat, lon }, + timeLabel: toTimeLabel(pointDate) + ' 최근접', + progressPercent, + }; +} + +// ============================================================ +// 메인 분석 함수 (외부에서 호출) +// ============================================================ +export async function runBacktrackAnalysis(backtrackSn: number): Promise { + await wingPool.query( + `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='RUNNING' WHERE BACKTRACK_SN=$1`, + [backtrackSn], + ); + + try { + const bt = await getBacktrack(backtrackSn); + if (!bt || bt.lat == null || bt.lon == null || !bt.estSpilDtm) { + throw new Error('역추적 레코드 정보 불충분 (lat/lon/estSpilDtm 필요)'); + } + + const anlysHours = parseAnalysisHours(bt.anlysRange ?? '12'); + const spillTime = new Date(bt.estSpilDtm); + const analysisStart = new Date(spillTime.getTime() - anlysHours * 3600000); + + // SPIL_DATA에서 유출량 및 유종 조회 + let matVol: number | null = null; + let matTy: string | undefined; + try { + const { rows: spillRows } = await wingPool.query( + `SELECT SPIL_QTY, OIL_TP_CD FROM wing.SPIL_DATA WHERE ACDNT_SN=$1 ORDER BY SPIL_DATA_SN ASC LIMIT 1`, + [bt.acdntSn], + ); + if (spillRows.length > 0) { + const row = spillRows[0] as Record; + matVol = row['spil_qty'] != null ? Number(row['spil_qty']) : null; + const oilTpCd = row['oil_tp_cd'] as string | null; + matTy = oilTpCd ? (OIL_TYPE_MAP[oilTpCd] ?? oilTpCd) : undefined; + } + } catch (spillErr) { + console.warn('[backtrack] SPIL_DATA 조회 실패, matVol 없이 진행:', spillErr); + } + + // Python 역방향 시뮬레이션 실행 (파티클 시각화용) + let rawResult: PythonTimeStep[]; + try { + const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lat: bt.lat, + lon: bt.lon, + startTime: spillTime.toISOString(), + runTime: anlysHours, + matVol: matVol ?? 1, + matTy, + name: `BACKTRACK_${backtrackSn}`, + }), + signal: AbortSignal.timeout(30000), + }); + if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`); + const { job_id } = await pythonRes.json() as { job_id: string }; + rawResult = await pollUntilDone(job_id); + } catch (pyErr) { + // Python 미연동 시 폴백: 빈 파티클 스텝 생성 + console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr); + rawResult = Array.from({ length: anlysHours + 1 }, () => ({ + particles: [], + remaining_volume_m3: 0, + weathered_volume_m3: 0, + pollution_area_km2: 0, + beached_volume_m3: 0, + pollution_coast_length_m: 0, + })); + } + + const steps = computeParticleSteps(rawResult, spillTime, anlysHours); + if (rawResult.every(s => s.particles.length === 0)) { + steps.forEach((s, i) => { + s.centroid = { lat: bt.lat!, lon: bt.lon! }; + s.radiusNm = (bt.srchRadiusNm ?? 10) + i * 2; + }); + } + + // 선박 항적 API 호출 + const srchRadius = bt.srchRadiusNm ?? 10; + const apiResponse = await fetchVesselTracks( + bt.lat, bt.lon, srchRadius, analysisStart, spillTime, + ); + + const totalVessels = apiResponse.summary.totalVessels; + const ranked = scoreAndRankVesselsFromApi( + apiResponse.tracks, + apiResponse.hitDetails, + bt.lat, bt.lon, srchRadius, anlysHours, + ); + const replayShips = buildReplayShipsFromApi(ranked); + const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime); + + const timeRange = { + start: analysisStart.toISOString(), + end: spillTime.toISOString(), + }; + + // vessels에서 내부 필드 제거 + const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v); + + const rsltData = { vessels, replayShips, collisionEvent, timeRange }; + + await wingPool.query( + `UPDATE wing.BACKTRACK + SET EXEC_STTS_CD='COMPLETED', RSLT_DATA=$1, TOTAL_VESSELS=$2 + WHERE BACKTRACK_SN=$3`, + [JSON.stringify(rsltData), totalVessels, backtrackSn], + ); + + console.info(`[backtrack] 분석 완료 SN=${backtrackSn}, 후보선박=${ranked.length}/${totalVessels}`); + } catch (err) { + console.error('[backtrack] 분석 실패:', err); + const errMsg = err instanceof Error ? err.message : '알 수 없는 오류'; + await wingPool.query( + `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='FAILED', RSLT_DATA=$1 WHERE BACKTRACK_SN=$2`, + [JSON.stringify({ error: errMsg }), backtrackSn], + ); + } +} diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 2c3dde0..e5ec6cb 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -1,4 +1,5 @@ import { wingPool } from '../db/wingDb.js'; +import { runBacktrackAnalysis } from './backtrackAnalysisService.js'; interface PredictionAnalysis { acdntSn: number; @@ -373,7 +374,7 @@ function rowToBacktrack(r: Record): BacktrackResult { return { backtrackSn: Number(r['backtrack_sn']), acdntSn: Number(r['acdnt_sn']), - estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null, + estSpilDtm: r['est_spil_dtm'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : null, anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null, lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null, lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null, @@ -387,15 +388,15 @@ function rowToBacktrack(r: Record): BacktrackResult { export async function createBacktrack( input: CreateBacktrackInput, -): Promise<{ backtrackSn: number }> { +): Promise { const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input; const sql = ` INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD) VALUES ( - $1, $2, $3, - ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), - $3 || ' + ' || $2, + $1, $2::double precision, $3::double precision, + ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326), + $3::text || ' + ' || $2::text, $4, $5, $6, 'PENDING' ) RETURNING BACKTRACK_SN @@ -405,8 +406,14 @@ export async function createBacktrack( acdntSn, lat, lon, estSpilDtm || null, anlysRange || null, srchRadiusNm || null, ]); + const backtrackSn = Number((rows[0] as Record)['backtrack_sn']); - return { backtrackSn: Number((rows[0] as Record)['backtrack_sn']) }; + // 동기 분석 (완료까지 대기 후 결과 반환) + await runBacktrackAnalysis(backtrackSn); + + const result = await getBacktrack(backtrackSn); + if (!result) throw new Error('역추적 결과를 찾을 수 없습니다'); + return result; } export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> { diff --git a/database/migration/030_ais_track.sql b/database/migration/030_ais_track.sql new file mode 100644 index 0000000..15f576c --- /dev/null +++ b/database/migration/030_ais_track.sql @@ -0,0 +1,19 @@ +-- AIS 선박 위치 이력 테이블 +CREATE TABLE IF NOT EXISTS wing.AIS_TRACK ( + AIS_TRACK_SN SERIAL PRIMARY KEY, + MMSI VARCHAR(12) NOT NULL, + IMO VARCHAR(12), + VESSEL_NM VARCHAR(100), + VESSEL_TP SMALLINT, + LAT NUMERIC(9,6), + LON NUMERIC(10,6), + SPEED NUMERIC(5,1), + COURSE NUMERIC(5,1), + NAV_STATUS SMALLINT, + OBS_DTM TIMESTAMPTZ NOT NULL, + GEOM GEOMETRY(Point, 4326), + SRC_CD VARCHAR(20) DEFAULT 'API' +); +CREATE INDEX IF NOT EXISTS idx_ais_track_mmsi ON wing.AIS_TRACK(MMSI); +CREATE INDEX IF NOT EXISTS idx_ais_track_obs_dtm ON wing.AIS_TRACK(OBS_DTM); +CREATE INDEX IF NOT EXISTS idx_ais_track_geom ON wing.AIS_TRACK USING GIST(GEOM); diff --git a/frontend/src/common/components/map/BacktrackReplayBar.tsx b/frontend/src/common/components/map/BacktrackReplayBar.tsx index bf7d533..8bbaf4f 100755 --- a/frontend/src/common/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/common/components/map/BacktrackReplayBar.tsx @@ -11,6 +11,7 @@ interface BacktrackReplayBarProps { onClose: () => void replayShips: ReplayShip[] collisionEvent: CollisionEvent | null + replayTimeRange?: { start: string; end: string } } export function BacktrackReplayBar({ @@ -24,16 +25,44 @@ export function BacktrackReplayBar({ onClose, replayShips, collisionEvent, + replayTimeRange, }: BacktrackReplayBarProps) { const progress = (replayFrame / totalFrames) * 100 - // Time calculation: 12-hour span from 18:30 to 06:30 - const hours = 18.5 + (replayFrame / totalFrames) * 12 - const displayHours = hours >= 24 ? hours - 24 : hours - const h = Math.floor(displayHours) - const m = Math.round((displayHours - h) * 60) - const dayLabel = hours >= 24 ? '02-10' : '02-09' - const currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST` + // 타임 계산 + let startLabel: string + let endLabel: string + let currentTimeLabel: string + + if (replayTimeRange) { + const startMs = new Date(replayTimeRange.start).getTime() + const endMs = new Date(replayTimeRange.end).getTime() + const currentMs = startMs + (replayFrame / totalFrames) * (endMs - startMs) + const fmt = (ms: number) => { + const d = new Date(ms + 9 * 3600000) // KST + const mo = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + const hh = String(d.getUTCHours()).padStart(2, '0') + const mm = String(d.getUTCMinutes()).padStart(2, '0') + return { date: `${mo}-${day}`, time: `${hh}:${mm}` } + } + const startFmt = fmt(startMs) + const endFmt = fmt(endMs) + const curFmt = fmt(currentMs) + startLabel = `${startFmt.date} ${startFmt.time}` + endLabel = `${endFmt.date} ${endFmt.time}` + currentTimeLabel = `${curFmt.date} ${curFmt.time} KST` + } else { + // 기존 하드코딩 폴백 + const hours = 18.5 + (replayFrame / totalFrames) * 12 + const displayHours = hours >= 24 ? hours - 24 : hours + const h = Math.floor(displayHours) + const m = Math.round((displayHours - h) * 60) + const dayLabel = hours >= 24 ? '02-10' : '02-09' + startLabel = '18:30' + endLabel = '06:30' + currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST` + } const handleSeekClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() @@ -160,9 +189,9 @@ export function BacktrackReplayBar({ {/* Time labels */}
- 18:30 + {startLabel} {currentTimeLabel} - 06:30 + {endLabel}
diff --git a/frontend/src/common/types/backtrack.ts b/frontend/src/common/types/backtrack.ts index 3511ed2..cc7a39e 100755 --- a/frontend/src/common/types/backtrack.ts +++ b/frontend/src/common/types/backtrack.ts @@ -43,3 +43,16 @@ export interface CollisionEvent { export type BacktrackPhase = 'conditions' | 'analyzing' | 'results' | 'replay' export const TOTAL_REPLAY_FRAMES = 120 + +// 역추적 분석 실행 시 사용자가 입력하는 조건 +export interface BacktrackInputConditions { + estimatedSpillTime: string // datetime-local input 값 (YYYY-MM-DDTHH:mm) + analysisRange: string // '6', '12', '24' (시간 단위) + searchRadius: number // NM 단위 +} + +// RSLT_DATA에 저장되는 분석 결과 타임 레인지 +export interface BacktrackTimeRange { + start: string // ISO 문자열 + end: string // ISO 문자열 +} diff --git a/frontend/src/tabs/prediction/components/BacktrackModal.tsx b/frontend/src/tabs/prediction/components/BacktrackModal.tsx index e263927..b3dddae 100755 --- a/frontend/src/tabs/prediction/components/BacktrackModal.tsx +++ b/frontend/src/tabs/prediction/components/BacktrackModal.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect } from 'react' -import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '@common/types/backtrack' +import { useRef, useEffect, useState } from 'react' +import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions } from '@common/types/backtrack' interface BacktrackModalProps { isOpen: boolean @@ -7,10 +7,24 @@ interface BacktrackModalProps { phase: BacktrackPhase conditions: BacktrackConditions vessels: BacktrackVessel[] - onRunAnalysis: () => void + onRunAnalysis: (input: BacktrackInputConditions) => void onStartReplay: () => void } +const toDateTimeLocalValue = (raw: string): string => { + if (!raw) return '' + const d = new Date(raw) + if (isNaN(d.getTime())) return '' + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +const nowDateTimeLocalValue = (): string => { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + export function BacktrackModal({ isOpen, onClose, @@ -22,6 +36,11 @@ export function BacktrackModal({ }: BacktrackModalProps) { const backdropRef = useRef(null) + const [inputTimeOverride, setInputTime] = useState(undefined) + const inputTime = inputTimeOverride ?? toDateTimeLocalValue(conditions.estimatedSpillTime) ?? nowDateTimeLocalValue() + const [inputRange, setInputRange] = useState('12') + const [inputRadius, setInputRadius] = useState(10) + useEffect(() => { const handler = (e: MouseEvent) => { if (e.target === backdropRef.current) onClose() @@ -32,6 +51,21 @@ export function BacktrackModal({ if (!isOpen) return null + const inputDisabled = phase !== 'conditions' + + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '6px 8px', + background: 'var(--bg3)', + border: '1px solid var(--bd)', + borderRadius: '6px', + color: 'var(--t1)', + fontSize: '11px', + fontFamily: 'var(--fM)', + outline: 'none', + opacity: inputDisabled ? 0.6 : 1, + } + return (
- {[ - { label: '유출 추정 시각', value: conditions.estimatedSpillTime }, - { label: '분석 범위', value: conditions.analysisRange }, - { label: '탐색 반경', value: conditions.searchRadius }, - { label: '유출 위치', value: `${conditions.spillLocation.lat.toFixed(4)}°N, ${conditions.spillLocation.lon.toFixed(4)}°E` }, - ].map((item, i) => ( -
-
- {item.label} -
-
- {item.value} -
+ {/* 유출 추정 시각 */} +
+
+ 유출 추정 시각
- ))} + setInputTime(e.target.value)} + disabled={inputDisabled} + style={inputStyle} + /> +
+ + {/* 분석 범위 */} +
+
+ 분석 범위 +
+ +
+ + {/* 탐색 반경 */} +
+
+ 탐색 반경 +
+
+ setInputRadius(Number(e.target.value))} + disabled={inputDisabled} + min={1} + max={100} + step={0.5} + style={{ ...inputStyle, flex: 1 }} + /> + NM +
+
+ + {/* 유출 위치 */} +
+
+ 유출 위치 +
+
+ {conditions.spillLocation.lat.toFixed(4)}°N, {conditions.spillLocation.lon.toFixed(4)}°E +
+
+ + {/* 분석 대상 선박 */}
{phase === 'conditions' && ( {/* Timeline */}
{/* Progress bar */}
{/* Legend row */} -
+
{replayShips.map((ship) => (
@@ -206,6 +246,12 @@ export function BacktrackReplayBar({
))} + {hasBackwardParticles && ( +
+
+ 역방향 예측 +
+ )}
) diff --git a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx index 42bc289..c80f47c 100755 --- a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx @@ -1,7 +1,29 @@ -import { ScatterplotLayer, PathLayer } from '@deck.gl/layers' -import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' +import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers' +import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack' import { hexToRgba } from './mapUtils' +// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산 +function convexHull(points: [number, number][]): [number, number][] { + if (points.length < 3) return points + const cross = (O: [number, number], A: [number, number], B: [number, number]) => + (A[0] - O[0]) * (B[1] - O[1]) - (A[1] - O[1]) * (B[0] - O[0]) + const sorted = [...points].sort((a, b) => a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]) + const lower: [number, number][] = [] + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop() + lower.push(p) + } + const upper: [number, number][] = [] + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i] + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop() + upper.push(p) + } + lower.pop() + upper.pop() + return [...lower, ...upper] +} + function getInterpolatedPosition( path: ReplayPathPoint[], frame: number, @@ -24,11 +46,14 @@ interface BacktrackReplayParams { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } +const BACKWARD_COLOR: [number, number, number, number] = [168, 85, 247, 160] + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createBacktrackLayers(params: BacktrackReplayParams): any[] { - const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params + const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord, backwardParticles } = params // eslint-disable-next-line @typescript-eslint/no-explicit-any const layers: any[] = [] const progress = replayFrame / totalFrames @@ -145,5 +170,46 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] { } } + // 역방향 예측 파티클 + 전체 경로 외각 폴리곤 + if (backwardParticles && backwardParticles.length > 0) { + // 전체 스텝의 모든 파티클을 합쳐 외각 폴리곤 계산 (정적 — 항상 표시) + const allPoints = backwardParticles.flat() + if (allPoints.length >= 3) { + const hull = convexHull(allPoints) + if (hull.length >= 3) { + layers.push( + new PolygonLayer({ + id: 'bt-backward-hull', + data: [{ polygon: hull }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [168, 85, 247, 18], + getLineColor: [168, 85, 247, 110], + getLineWidth: 1, + stroked: true, + filled: true, + lineWidthMinPixels: 1, + }) + ) + } + } + + // 현재 프레임 파티클 + const stepIndex = Math.round((1 - progress) * (backwardParticles.length - 1)) + const particles = backwardParticles[stepIndex] ?? [] + if (particles.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'bt-backward-particles', + data: particles, + getPosition: (d: [number, number]) => d, + getRadius: 3, + getFillColor: BACKWARD_COLOR, + radiusMinPixels: 2.5, + radiusMaxPixels: 5, + }) + ) + } + } + return layers } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index c7d29f2..a287784 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -11,7 +11,7 @@ import type { PredictionModel, SensitiveResource } from '@tabs/prediction/compon import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' -import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' +import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' import { buildMeasureLayers } from './measureLayers' import { MeasureOverlay } from './MeasureOverlay' @@ -358,6 +358,7 @@ interface MapViewProps { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } sensitiveResources?: SensitiveResource[] sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null @@ -1100,6 +1101,7 @@ export function MapView({ replayFrame: backtrackReplay.replayFrame, totalFrames: backtrackReplay.totalFrames, incidentCoord: backtrackReplay.incidentCoord, + backwardParticles: backtrackReplay.backwardParticles, })) } diff --git a/frontend/src/common/types/backtrack.ts b/frontend/src/common/types/backtrack.ts index cc7a39e..269cc10 100755 --- a/frontend/src/common/types/backtrack.ts +++ b/frontend/src/common/types/backtrack.ts @@ -56,3 +56,6 @@ export interface BacktrackTimeRange { start: string // ISO 문자열 end: string // ISO 문자열 } + +// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열 +export type BackwardParticleStep = [number, number][] diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d1ecddc..da199bf 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, t import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils' import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' -import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' +import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi' import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi' @@ -194,6 +194,7 @@ export function OilSpillView() { const [replayShips, setReplayShips] = useState([]) const [collisionEvent, setCollisionEvent] = useState(null) const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null) + const [backwardParticles, setBackwardParticles] = useState([]) // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) @@ -250,6 +251,7 @@ export function OilSpillView() { if (rslt['timeRange']) { setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }) } + setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : []) setBacktrackConditions({ estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '', analysisRange: bt.anlysRange || '±12시간', @@ -308,6 +310,7 @@ export function OilSpillView() { if (Array.isArray(rslt['replayShips'])) setReplayShips(rslt['replayShips'] as ReplayShip[]) if (rslt['collisionEvent']) setCollisionEvent(rslt['collisionEvent'] as CollisionEvent) if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }) + setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : []) setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 })) setBacktrackPhase('results') } else { @@ -1051,6 +1054,7 @@ export function OilSpillView() { replayFrame, totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, + backwardParticles, } : undefined} showCurrent={displayControls.showCurrent} showWind={displayControls.showWind} @@ -1241,6 +1245,7 @@ export function OilSpillView() { replayShips={replayShips} collisionEvent={collisionEvent} replayTimeRange={replayTimeRange ?? undefined} + hasBackwardParticles={backwardParticles.length > 0} /> )} From 2cdd9cf52ba9f54d4adb4ddecab212c8dd1cd16e Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 27 Mar 2026 17:37:05 +0900 Subject: [PATCH 08/12] =?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 9a7e1e9..dcb15c4 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer) +- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시 +- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘) +- 역추적: 리플레이 바 — 드래그 시크 기능 추가 + ## [2026-03-27.2] ### 수정 From c92014f3a31b2f639906a48d012b650dd25f99a2 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 27 Mar 2026 17:44:32 +0900 Subject: [PATCH 09/12] =?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-27.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index dcb15c4..82cc3bc 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-27.3] + ### 추가 - 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer) - 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시 From e2254cc9608374d3f8d7fcdcd1c0e924419804f7 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 30 Mar 2026 14:46:37 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(tiles):=20VWorld=20=EC=9C=84?= =?UTF-8?q?=EC=84=B1=ED=83=80=EC=9D=BC=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VITE_VWORLD_API_KEY를 프론트엔드에서 직접 사용하던 방식에서 백엔드 프록시(/api/tiles/vworld)를 통해 API 키를 서버에서 관리하도록 변경. CORS 우회 + API 키 보호 효과. --- .claude/settings.json | 5 +- .claude/workflow-version.json | 4 +- backend/src/routes/tiles.ts | 49 +++++++++++++++++++ backend/src/server.ts | 5 +- .../src/common/components/map/MapView.tsx | 3 +- 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 backend/src/routes/tiles.ts diff --git a/.claude/settings.json b/.claude/settings.json index 43453f7..868df2d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,6 +83,5 @@ ] } ] - }, - "allow": [] -} \ No newline at end of file + } +} diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 003eaf0..7af3ce8 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-26", + "applied_date": "2026-03-30", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} \ No newline at end of file +} diff --git a/backend/src/routes/tiles.ts b/backend/src/routes/tiles.ts new file mode 100644 index 0000000..3b46048 --- /dev/null +++ b/backend/src/routes/tiles.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; + +const router = Router(); + +const VWORLD_API_KEY = process.env.VWORLD_API_KEY || ''; + +// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회) +// VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계 +router.get('/vworld/:z/:y/:x', async (req, res) => { + const { z, y } = req.params; + const x = req.params.x.replace(/\.jpeg$/i, ''); + + // z/y/x 정수 검증 (SSRF 방지) + if (!/^\d+$/.test(z) || !/^\d+$/.test(y) || !/^\d+$/.test(x)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + + if (!VWORLD_API_KEY) { + res.status(503).json({ error: 'VWorld API 키가 설정되지 않았습니다.' }); + return; + } + + const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`; + + try { + const upstream = await fetch(tileUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + + if (!upstream.ok) { + res.status(upstream.status).end(); + return; + } + + const contentType = upstream.headers.get('content-type') || 'image/jpeg'; + const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', cacheControl); + + const buffer = await upstream.arrayBuffer(); + res.end(Buffer.from(buffer)); + } catch { + res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' }); + } +}); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index cf7e6a1..a577c9d 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser' import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' +import tilesRouter from './routes/tiles.js' import authRouter from './auth/authRouter.js' import userRouter from './users/userRouter.js' import roleRouter from './roles/roleRouter.js' @@ -105,7 +106,8 @@ const generalLimiter = rateLimit({ legacyHeaders: false, skip: (req) => { // HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외 - return req.path.startsWith('/api/aerial/cctv/stream-proxy'); + return req.path.startsWith('/api/aerial/cctv/stream-proxy') || + req.path.startsWith('/api/tiles/'); }, message: { error: '요청 횟수 초과', @@ -172,6 +174,7 @@ app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) app.use('/api/monitor', monitorRouter) +app.use('/api/tiles', tilesRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index a287784..bda2fb7 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -20,7 +20,6 @@ import { hexToRgba } from './mapUtils' import { useMapStore } from '@common/store/mapStore' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' -const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' // 인천 송도 국제도시 const DEFAULT_CENTER: [number, number] = [37.39, 126.64] @@ -220,7 +219,7 @@ const SATELLITE_3D_STYLE: StyleSpecification = { sources: { 'vworld-satellite': { type: 'raster', - tiles: [`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`], + tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], tileSize: 256, attribution: '© 국토지리정보원 VWorld', }, From f3cfc869211bfde179b4e3d8d2a7bcb59a196955 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 30 Mar 2026 14:49:09 +0900 Subject: [PATCH 11/12] =?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 82cc3bc..65e427f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 지도: VWorld 위성타일 백엔드 프록시 추가 — API 키를 서버에서 관리하고 CORS 우회 + ## [2026-03-27.3] ### 추가 From d10b27db874ab679f8ed0c750d8e646edf55b1ec Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 30 Mar 2026 15:10:06 +0900 Subject: [PATCH 12/12] =?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-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 65e427f..2a62d7a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,22 +4,11 @@ ## [Unreleased] +## [2026-03-30] + ### 추가 - 지도: VWorld 위성타일 백엔드 프록시 추가 — API 키를 서버에서 관리하고 CORS 우회 -## [2026-03-27.3] - -### 추가 -- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer) -- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시 -- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘) -- 역추적: 리플레이 바 — 드래그 시크 기능 추가 - -## [2026-03-27.2] - -### 수정 -- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search) - ## [2026-03-27] ### 추가 @@ -28,6 +17,13 @@ - 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성 - 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시 - DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스) +- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer) +- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시 +- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘) +- 역추적: 리플레이 바 — 드래그 시크 기능 추가 + +### 수정 +- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search) ### 변경 - 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요) @@ -41,26 +37,20 @@ - 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정 - 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선) -## [2026-03-25.2] - -### 추가 -- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) -- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) -- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션) - -### 변경 -- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용 -- 사고: 선택된 항목 재클릭 시 선택 해제 지원 - ## [2026-03-25] ### 추가 - 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회) - DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn) - 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE) +- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) +- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) +- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션) ### 변경 - 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등) +- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용 +- 사고: 선택된 항목 재클릭 시 선택 해제 지원 ## [2026-03-24]