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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5d3d2a1..6fa28b3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,18 @@ ## [Unreleased] +## [2026-03-27] + +### 추가 +- 역추적: 사용자가 유출 추정 시각/분석 범위/탐색 반경을 직접 입력하는 분석 파라미터 UI 구현 +- 역추적: AIS 기반 선박 항적 API 연동 및 가중치 위험도 점수 산정 엔진 (backtrackAnalysisService) +- 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성 +- 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시 +- DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스) + +### 변경 +- 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요) + ## [2026-03-26] ### 추가 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' && (