Compare commits

...

3 커밋

작성자 SHA1 메시지 날짜
ff202c6e05 Merge pull request 'feat(prediction): ������ �м� ���� �� ���� �Ķ����� �Է� ���� ����' (#129) from feature/20260326 into develop 2026-03-27 15:07:29 +09:00
4620a2a3c9 docs: 릴리즈 노트 업데이트 2026-03-27 14:58:36 +09:00
e285f2330f feat(prediction): 역추적 분석 엔진 및 동적 파라미터 입력 기능 구현
- 백엔드: backtrackAnalysisService 신규 개발
  * AIS 기반 선박 항적 API 연동 및 공간 조회
  * 공간(40%)/시간(25%)/행동(20%)/선박유형(15%) 가중치 위험도 점수 산정
  * 상위 5척 리플레이 데이터 및 충돌 이벤트 생성
  * Python 서버 미연동 시 폴백 메커니즘 제공
- 백엔드: 역추적 생성 시 동기 분석 → BacktrackResult 즉시 반환
- 프론트엔드: 모달에서 유출 시각/분석 범위/탐색 반경 직접 입력 가능
- 프론트엔드: 리플레이 바에 실제 분석 시간 범위 동적 표시
- DB: AIS_TRACK 테이블 신규 생성 (선박 항적 이력 + GIS 인덱스)
2026-03-27 14:57:00 +09:00
9개의 변경된 파일797개의 추가작업 그리고 71개의 파일을 삭제

파일 보기

@ -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<string, string> = {
'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<string, HitDetail[]>;
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<PythonTimeStep[]> {
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
await new Promise<void>(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<VesselTrackApiResponse> {
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<VesselTrackApiResponse>;
}
// 스코어링 + 순위 (선박 항적 API 응답 기반)
function scoreAndRankVesselsFromApi(
tracks: VesselTrack[],
hitDetails: Record<string, HitDetail[]>,
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<void> {
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<string, unknown>;
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],
);
}
}

파일 보기

@ -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<string, unknown>): 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<string, unknown>): BacktrackResult {
export async function createBacktrack(
input: CreateBacktrackInput,
): Promise<{ backtrackSn: number }> {
): Promise<BacktrackResult> {
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<string, unknown>)['backtrack_sn']);
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['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 }> {

파일 보기

@ -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);

파일 보기

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

파일 보기

@ -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<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
@ -160,9 +189,9 @@ export function BacktrackReplayBar({
{/* Time labels */}
<div className="flex justify-between text-[9px] font-mono">
<span className="text-text-3">18:30</span>
<span className="text-text-3">{startLabel}</span>
<span className="font-semibold text-primary-purple">{currentTimeLabel}</span>
<span className="text-text-3">06:30</span>
<span className="text-text-3">{endLabel}</span>
</div>
</div>
</div>

파일 보기

@ -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 문자열
}

파일 보기

@ -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<HTMLDivElement>(null)
const [inputTimeOverride, setInputTime] = useState<string | undefined>(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 (
<div
ref={backdropRef}
@ -92,24 +126,80 @@ export function BacktrackModal({
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px',
}}>
{[
{ 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) => (
<div key={i} style={{
padding: '10px 12px', background: 'var(--bg3)',
borderRadius: '8px',
}} className="border border-border">
<div className="text-[9px] text-text-3 mb-1">
{item.label}
</div>
<div className="text-[12px] font-semibold font-mono">
{item.value}
</div>
{/* 유출 추정 시각 */}
<div style={{
padding: '10px 12px', background: 'var(--bg3)',
borderRadius: '8px',
}} className="border border-border">
<div className="text-[9px] text-text-3 mb-1">
</div>
))}
<input
type="datetime-local"
value={inputTime}
onChange={e => setInputTime(e.target.value)}
disabled={inputDisabled}
style={inputStyle}
/>
</div>
{/* 분석 범위 */}
<div style={{
padding: '10px 12px', background: 'var(--bg3)',
borderRadius: '8px',
}} className="border border-border">
<div className="text-[9px] text-text-3 mb-1">
</div>
<select
value={inputRange}
onChange={e => setInputRange(e.target.value)}
disabled={inputDisabled}
style={inputStyle}
>
<option value="6">±6</option>
<option value="12">±12</option>
<option value="24">±24</option>
</select>
</div>
{/* 탐색 반경 */}
<div style={{
padding: '10px 12px', background: 'var(--bg3)',
borderRadius: '8px',
}} className="border border-border">
<div className="text-[9px] text-text-3 mb-1">
</div>
<div className="flex items-center gap-1">
<input
type="number"
value={inputRadius}
onChange={e => setInputRadius(Number(e.target.value))}
disabled={inputDisabled}
min={1}
max={100}
step={0.5}
style={{ ...inputStyle, flex: 1 }}
/>
<span className="text-[10px] text-text-3 shrink-0">NM</span>
</div>
</div>
{/* 유출 위치 */}
<div style={{
padding: '10px 12px', background: 'var(--bg3)',
borderRadius: '8px',
}} className="border border-border">
<div className="text-[9px] text-text-3 mb-1">
</div>
<div className="text-[12px] font-semibold font-mono">
{conditions.spillLocation.lat.toFixed(4)}°N, {conditions.spillLocation.lon.toFixed(4)}°E
</div>
</div>
{/* 분석 대상 선박 */}
<div style={{
padding: '10px 12px', background: 'var(--bg3)',
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
@ -155,7 +245,7 @@ export function BacktrackModal({
}} className="border-t border-border flex gap-2">
{phase === 'conditions' && (
<button
onClick={onRunAnalysis}
onClick={() => onRunAnalysis({ estimatedSpillTime: inputTime, analysisRange: inputRange, searchRadius: inputRadius })}
style={{
padding: '12px',
borderRadius: '8px',

파일 보기

@ -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, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent } 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'
@ -193,6 +193,7 @@ export function OilSpillView() {
})
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
@ -235,7 +236,7 @@ export function OilSpillView() {
const loadBacktrackData = useCallback(async (acdntSn: number) => {
try {
const bt = await fetchBacktrackByAcdnt(acdntSn)
if (bt && bt.execSttsCd === 'completed' && bt.rsltData) {
if (bt && bt.execSttsCd === 'COMPLETED' && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
@ -246,6 +247,9 @@ export function OilSpillView() {
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
if (rslt['timeRange']) {
setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
}
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시간',
@ -272,6 +276,9 @@ export function OilSpillView() {
setBacktrackConditions(prev => ({
...prev,
spillLocation: incidentCoord ?? prev.spillLocation,
estimatedSpillTime: selectedAnalysis?.occurredAt
? toLocalDateTimeStr(selectedAnalysis.occurredAt)
: prev.estimatedSpillTime,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
@ -281,39 +288,31 @@ export function OilSpillView() {
}
}
const handleRunBacktrackAnalysis = async () => {
if (!incidentCoord) return
const handleRunBacktrackAnalysis = async (input: BacktrackInputConditions) => {
if (!incidentCoord || !selectedAnalysis) return
setBacktrackPhase('analyzing')
try {
if (selectedAnalysis) {
const { backtrackSn } = await createBacktrack({
acdntSn: selectedAnalysis.acdntSn,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
})
// 생성 후 기존 결과 로딩 (시드 데이터 또는 엔진 처리 결과)
const bt = await fetchBacktrackByAcdnt(selectedAnalysis.acdntSn)
if (bt && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
}
if (Array.isArray(rslt.replayShips)) {
setReplayShips(rslt.replayShips as ReplayShip[])
}
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
setBacktrackConditions(prev => ({
...prev,
totalVessels: bt.totalVessels || 0,
}))
setBacktrackPhase('results')
} else {
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
setBacktrackPhase('conditions')
}
const anlysRangeStr = `±${input.analysisRange}시간`
const bt = await createBacktrack({
acdntSn: selectedAnalysis.acdntSn,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
estSpilDtm: input.estimatedSpillTime ? new Date(input.estimatedSpillTime).toISOString() : undefined,
anlysRange: anlysRangeStr,
srchRadiusNm: input.searchRadius,
})
if (bt.execSttsCd === 'COMPLETED' && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt['vessels'])) setBacktrackVessels(rslt['vessels'] as BacktrackVessel[])
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 })
setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 }))
setBacktrackPhase('results')
} else {
console.error('[prediction] 역추적 분석 실패:', bt.execSttsCd)
setBacktrackPhase('conditions')
}
} catch (err) {
console.error('[prediction] 역추적 분석 실패:', err)
@ -1241,6 +1240,7 @@ export function OilSpillView() {
onClose={handleCloseReplay}
replayShips={replayShips}
collisionEvent={collisionEvent}
replayTimeRange={replayTimeRange ?? undefined}
/>
)}
</>

파일 보기

@ -115,8 +115,9 @@ export const createBacktrack = async (input: {
lat: number;
srchRadiusNm?: number;
anlysRange?: string;
}): Promise<{ backtrackSn: number }> => {
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
estSpilDtm?: string;
}): Promise<BacktrackResult> => {
const response = await api.post<BacktrackResult>('/prediction/backtrack', input);
return response.data;
};