Merge remote-tracking branch 'origin/develop' into feature/predict-develop
# Conflicts: # docs/RELEASE-NOTES.md # frontend/src/common/components/map/BacktrackReplayBar.tsx # frontend/src/tabs/prediction/components/BacktrackModal.tsx
This commit is contained in:
커밋
0e6d63f1f0
@ -83,6 +83,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"allow": []
|
}
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-26",
|
"applied_date": "2026-03-30",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
}
|
}
|
||||||
|
|||||||
563
backend/src/prediction/backtrackAnalysisService.ts
Normal file
563
backend/src/prediction/backtrackAnalysisService.ts
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
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 ?? 'https://guide.gc-si.dev/signal-batch';
|
||||||
|
const VESSEL_TRACK_COOKIE = process.env.VESSEL_TRACK_COOKIE ?? '';
|
||||||
|
|
||||||
|
// 유종 코드(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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 파티클 스텝별 탐색 영역 계산
|
||||||
|
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('[backtrack] VESSEL_TRACK_API 호출', {
|
||||||
|
url: `${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`,
|
||||||
|
startTime: body.startTime,
|
||||||
|
endTime: body.endTime,
|
||||||
|
srchRadiusNm,
|
||||||
|
polygons: body.polygons.length,
|
||||||
|
hasCookie: !!VESSEL_TRACK_COOKIE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(VESSEL_TRACK_COOKIE ? { 'Cookie': VESSEL_TRACK_COOKIE } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[backtrack] VESSEL_TRACK_API 응답', { status: res.status, ok: res.ok });
|
||||||
|
if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`);
|
||||||
|
const data = await res.json() as VesselTrackApiResponse;
|
||||||
|
console.log('[backtrack] VESSEL_TRACK_API 데이터', { totalVessels: data.summary?.totalVessels ?? 0, tracks: data.tracks?.length ?? 0 });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스코어링 + 순위 (선박 항적 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 역방향 시뮬레이션 실행 (파티클 시각화용)
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 요청', { lat: bt.lat, lon: bt.lon, anlysHours, matVol, matTy, spillTime: spillTime.toISOString() });
|
||||||
|
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 pythonData = await pythonRes.json() as { success: boolean; result: PythonTimeStep[] };
|
||||||
|
rawResult = pythonData.result ?? [];
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 완료 — steps:', rawResult.length);
|
||||||
|
} 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;
|
||||||
|
console.log('[backtrack] 선박 항적 API 호출 시작', { srchRadius, analysisStart: analysisStart.toISOString(), spillTime: spillTime.toISOString() });
|
||||||
|
const apiResponse = await fetchVesselTracks(
|
||||||
|
bt.lat, bt.lon, srchRadius, analysisStart, spillTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalVessels = apiResponse.summary.totalVessels;
|
||||||
|
console.log('[backtrack] 선박 점수 계산 시작 — totalVessels:', totalVessels);
|
||||||
|
const ranked = scoreAndRankVesselsFromApi(
|
||||||
|
apiResponse.tracks,
|
||||||
|
apiResponse.hitDetails,
|
||||||
|
bt.lat, bt.lon, srchRadius, anlysHours,
|
||||||
|
);
|
||||||
|
console.log('[backtrack] 선박 점수 계산 완료 — ranked:', ranked.length, '/', totalVessels, '| top:', ranked[0]?.name, ranked[0]?.probability);
|
||||||
|
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);
|
||||||
|
|
||||||
|
// rawResult 샘플링 (최대 24 스텝, 파티클은 [lon, lat] 형식으로 저장)
|
||||||
|
const MAX_BACKWARD_STEPS = 24;
|
||||||
|
const sampleRate = Math.max(1, Math.ceil(rawResult.length / MAX_BACKWARD_STEPS));
|
||||||
|
const backwardParticles = rawResult
|
||||||
|
.filter((_, i) => i % sampleRate === 0)
|
||||||
|
.slice(0, MAX_BACKWARD_STEPS)
|
||||||
|
.map(step => step.particles.map(p => [p.lon, p.lat] as [number, number]));
|
||||||
|
|
||||||
|
const rsltData = { vessels, replayShips, collisionEvent, timeRange, backwardParticles };
|
||||||
|
|
||||||
|
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 { wingPool } from '../db/wingDb.js';
|
||||||
|
import { runBacktrackAnalysis } from './backtrackAnalysisService.js';
|
||||||
|
|
||||||
interface PredictionAnalysis {
|
interface PredictionAnalysis {
|
||||||
acdntSn: number;
|
acdntSn: number;
|
||||||
@ -373,7 +374,7 @@ function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
|||||||
return {
|
return {
|
||||||
backtrackSn: Number(r['backtrack_sn']),
|
backtrackSn: Number(r['backtrack_sn']),
|
||||||
acdntSn: Number(r['acdnt_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,
|
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
||||||
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
||||||
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : 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(
|
export async function createBacktrack(
|
||||||
input: CreateBacktrackInput,
|
input: CreateBacktrackInput,
|
||||||
): Promise<{ backtrackSn: number }> {
|
): Promise<BacktrackResult> {
|
||||||
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3,
|
$1, $2::double precision, $3::double precision,
|
||||||
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
|
ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326),
|
||||||
$3 || ' + ' || $2,
|
$3::text || ' + ' || $2::text,
|
||||||
$4, $5, $6, 'PENDING'
|
$4, $5, $6, 'PENDING'
|
||||||
)
|
)
|
||||||
RETURNING BACKTRACK_SN
|
RETURNING BACKTRACK_SN
|
||||||
@ -405,8 +406,14 @@ export async function createBacktrack(
|
|||||||
acdntSn, lat, lon,
|
acdntSn, lat, lon,
|
||||||
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
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 }> {
|
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
||||||
|
|||||||
49
backend/src/routes/tiles.ts
Normal file
49
backend/src/routes/tiles.ts
Normal file
@ -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;
|
||||||
@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser'
|
|||||||
import { testWingDbConnection } from './db/wingDb.js'
|
import { testWingDbConnection } from './db/wingDb.js'
|
||||||
import layersRouter from './routes/layers.js'
|
import layersRouter from './routes/layers.js'
|
||||||
import simulationRouter from './routes/simulation.js'
|
import simulationRouter from './routes/simulation.js'
|
||||||
|
import tilesRouter from './routes/tiles.js'
|
||||||
import authRouter from './auth/authRouter.js'
|
import authRouter from './auth/authRouter.js'
|
||||||
import userRouter from './users/userRouter.js'
|
import userRouter from './users/userRouter.js'
|
||||||
import roleRouter from './roles/roleRouter.js'
|
import roleRouter from './roles/roleRouter.js'
|
||||||
@ -105,7 +106,8 @@ const generalLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skip: (req) => {
|
skip: (req) => {
|
||||||
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
// 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: {
|
message: {
|
||||||
error: '요청 횟수 초과',
|
error: '요청 횟수 초과',
|
||||||
@ -172,6 +174,7 @@ app.use('/api/aerial', aerialRouter)
|
|||||||
app.use('/api/rescue', rescueRouter)
|
app.use('/api/rescue', rescueRouter)
|
||||||
app.use('/api/map-base', mapBaseRouter)
|
app.use('/api/map-base', mapBaseRouter)
|
||||||
app.use('/api/monitor', monitorRouter)
|
app.use('/api/monitor', monitorRouter)
|
||||||
|
app.use('/api/tiles', tilesRouter)
|
||||||
|
|
||||||
// 헬스 체크
|
// 헬스 체크
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
|
|||||||
19
database/migration/030_ais_track.sql
Normal file
19
database/migration/030_ais_track.sql
Normal file
@ -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);
|
||||||
@ -13,6 +13,30 @@
|
|||||||
- PretendardGOV 폰트 적용
|
- PretendardGOV 폰트 적용
|
||||||
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
- 라이트 테마 CSS 변수 오버라이드 및 컴포넌트별 스타일 대응
|
||||||
|
|
||||||
|
## [2026-03-30]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 지도: VWorld 위성타일 백엔드 프록시 추가 — API 키를 서버에서 관리하고 CORS 우회
|
||||||
|
|
||||||
|
## [2026-03-27]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 역추적: 사용자가 유출 추정 시각/분석 범위/탐색 반경을 직접 입력하는 분석 파라미터 UI 구현
|
||||||
|
- 역추적: AIS 기반 선박 항적 API 연동 및 가중치 위험도 점수 산정 엔진 (backtrackAnalysisService)
|
||||||
|
- 역추적: 상위 5척 선박 경로 및 충돌 이벤트 리플레이 데이터 생성
|
||||||
|
- 역추적: 리플레이 바에 실제 분석 시간 범위 동적 표시
|
||||||
|
- DB: AIS_TRACK 테이블 추가 (선박 항적 이력, GIS 공간 인덱스)
|
||||||
|
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
|
||||||
|
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
|
||||||
|
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
|
||||||
|
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- 역추적: 선박 항적 API URL을 프로덕션 URL로 변경 및 엔드포인트 경로 추가 (/api/v2/tracks/area-search)
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 역추적: 생성 API 응답을 BacktrackResult로 통합 (재조회 불필요)
|
||||||
|
|
||||||
## [2026-03-26]
|
## [2026-03-26]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
@ -22,26 +46,20 @@
|
|||||||
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
|
- 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정
|
||||||
- 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선)
|
- 확산예측: 분석 목록 정렬 기준 변경 (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]
|
## [2026-03-25]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
- 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회)
|
- 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회)
|
||||||
- DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn)
|
- DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn)
|
||||||
- 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE)
|
- 관리자: 수치예측자료 연계 모니터링 패널 추가 (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 등)
|
- 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등)
|
||||||
|
- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용
|
||||||
|
- 사고: 선택된 항목 재클릭 시 선택 해제 지원
|
||||||
|
|
||||||
## [2026-03-24]
|
## [2026-03-24]
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
|
|
||||||
interface BacktrackReplayBarProps {
|
interface BacktrackReplayBarProps {
|
||||||
@ -11,6 +12,8 @@ interface BacktrackReplayBarProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
replayShips: ReplayShip[]
|
replayShips: ReplayShip[]
|
||||||
collisionEvent: CollisionEvent | null
|
collisionEvent: CollisionEvent | null
|
||||||
|
replayTimeRange?: { start: string; end: string }
|
||||||
|
hasBackwardParticles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacktrackReplayBar({
|
export function BacktrackReplayBar({
|
||||||
@ -24,23 +27,88 @@ export function BacktrackReplayBar({
|
|||||||
onClose,
|
onClose,
|
||||||
replayShips,
|
replayShips,
|
||||||
collisionEvent,
|
collisionEvent,
|
||||||
|
replayTimeRange,
|
||||||
|
hasBackwardParticles,
|
||||||
}: BacktrackReplayBarProps) {
|
}: BacktrackReplayBarProps) {
|
||||||
const progress = (replayFrame / totalFrames) * 100
|
const progress = (replayFrame / totalFrames) * 100
|
||||||
|
const isFinished = replayFrame >= totalFrames
|
||||||
|
|
||||||
// Time calculation: 12-hour span from 18:30 to 06:30
|
// 드래그 시크
|
||||||
const hours = 18.5 + (replayFrame / totalFrames) * 12
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
const displayHours = hours >= 24 ? hours - 24 : hours
|
const isDraggingRef = useRef(false)
|
||||||
const h = Math.floor(displayHours)
|
const onSeekRef = useRef(onSeek)
|
||||||
const m = Math.round((displayHours - h) * 60)
|
const totalFramesRef = useRef(totalFrames)
|
||||||
const dayLabel = hours >= 24 ? '02-10' : '02-09'
|
|
||||||
const currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`
|
|
||||||
|
|
||||||
const handleSeekClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
useEffect(() => { onSeekRef.current = onSeek }, [onSeek])
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
useEffect(() => { totalFramesRef.current = totalFrames }, [totalFrames])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isDraggingRef.current || !barRef.current) return
|
||||||
|
const rect = barRef.current.getBoundingClientRect()
|
||||||
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
|
onSeekRef.current(Math.round(pct * totalFramesRef.current))
|
||||||
|
}
|
||||||
|
const onMouseUp = () => { isDraggingRef.current = false }
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBarMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
isDraggingRef.current = true
|
||||||
|
if (!barRef.current) return
|
||||||
|
const rect = barRef.current.getBoundingClientRect()
|
||||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
onSeek(Math.round(pct * totalFrames))
|
onSeek(Math.round(pct * totalFrames))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 재생 완료 후 재생 버튼 클릭 → 처음부터 재시작
|
||||||
|
const handlePlayClick = () => {
|
||||||
|
if (!isPlaying && isFinished) {
|
||||||
|
onSeek(0)
|
||||||
|
}
|
||||||
|
onTogglePlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타임 계산
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute flex flex-col"
|
className="absolute flex flex-col"
|
||||||
@ -57,7 +125,7 @@ export function BacktrackReplayBar({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full bg-primary-purple"
|
className="w-2 h-2 rounded-full bg-color-tertiary"
|
||||||
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
style={{ boxShadow: '0 0 8px rgba(168,85,247,0.5)' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-bold">
|
<span className="text-xs font-bold">
|
||||||
@ -97,7 +165,7 @@ export function BacktrackReplayBar({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Play/Pause */}
|
{/* Play/Pause */}
|
||||||
<button
|
<button
|
||||||
onClick={onTogglePlay}
|
onClick={handlePlayClick}
|
||||||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
|
background: isPlaying ? 'var(--color-tertiary)' : 'rgba(168,85,247,0.15)',
|
||||||
@ -105,15 +173,16 @@ export function BacktrackReplayBar({
|
|||||||
color: isPlaying ? '#fff' : 'var(--color-tertiary)',
|
color: isPlaying ? '#fff' : 'var(--color-tertiary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying ? '⏸' : '▶'}
|
{isPlaying ? '⏸' : isFinished ? '↺' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="flex-1 flex flex-col gap-1">
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div
|
<div
|
||||||
|
ref={barRef}
|
||||||
className="relative h-5 flex items-center cursor-pointer"
|
className="relative h-5 flex items-center cursor-pointer"
|
||||||
onClick={handleSeekClick}
|
onMouseDown={handleBarMouseDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full h-1 bg-border relative overflow-visible"
|
className="w-full h-1 bg-border relative overflow-visible"
|
||||||
@ -160,15 +229,15 @@ export function BacktrackReplayBar({
|
|||||||
|
|
||||||
{/* Time labels */}
|
{/* Time labels */}
|
||||||
<div className="flex justify-between text-[9px] font-mono">
|
<div className="flex justify-between text-[9px] font-mono">
|
||||||
<span className="text-fg-disabled">18:30</span>
|
<span className="text-fg-disabled">{startLabel}</span>
|
||||||
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
||||||
<span className="text-fg-disabled">06:30</span>
|
<span className="text-fg-disabled">{endLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend row */}
|
{/* Legend row */}
|
||||||
<div className="flex items-center gap-[14px] pt-1 border-t border-stroke">
|
<div className="flex items-center gap-[14px] pt-1 border-t border-stroke flex-wrap">
|
||||||
{replayShips.map((ship) => (
|
{replayShips.map((ship) => (
|
||||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||||||
@ -177,6 +246,12 @@ export function BacktrackReplayBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{hasBackwardParticles && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: '#a855f7', opacity: 0.8 }} />
|
||||||
|
<span className="text-[9px] text-fg-sub font-mono">역방향 예측</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,29 @@
|
|||||||
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers'
|
||||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack'
|
||||||
import { hexToRgba } from './mapUtils'
|
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(
|
function getInterpolatedPosition(
|
||||||
path: ReplayPathPoint[],
|
path: ReplayPathPoint[],
|
||||||
frame: number,
|
frame: number,
|
||||||
@ -24,11 +46,14 @@ interface BacktrackReplayParams {
|
|||||||
replayFrame: number
|
replayFrame: number
|
||||||
totalFrames: number
|
totalFrames: number
|
||||||
incidentCoord: { lat: number; lon: 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function createBacktrackLayers(params: BacktrackReplayParams): 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layers: any[] = []
|
const layers: any[] = []
|
||||||
const progress = replayFrame / totalFrames
|
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
|
return layers
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { PredictionModel, SensitiveResource } from '@tabs/prediction/compon
|
|||||||
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
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 { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||||||
import { buildMeasureLayers } from './measureLayers'
|
import { buildMeasureLayers } from './measureLayers'
|
||||||
import { MeasureOverlay } from './MeasureOverlay'
|
import { MeasureOverlay } from './MeasureOverlay'
|
||||||
@ -20,7 +20,6 @@ import { hexToRgba } from './mapUtils'
|
|||||||
import { useMapStore } from '@common/store/mapStore'
|
import { useMapStore } from '@common/store/mapStore'
|
||||||
|
|
||||||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
|
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]
|
const DEFAULT_CENTER: [number, number] = [37.39, 126.64]
|
||||||
@ -220,7 +219,7 @@ const SATELLITE_3D_STYLE: StyleSpecification = {
|
|||||||
sources: {
|
sources: {
|
||||||
'vworld-satellite': {
|
'vworld-satellite': {
|
||||||
type: 'raster',
|
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,
|
tileSize: 256,
|
||||||
attribution: '© <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
|
attribution: '© <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
|
||||||
},
|
},
|
||||||
@ -358,6 +357,7 @@ interface MapViewProps {
|
|||||||
replayFrame: number
|
replayFrame: number
|
||||||
totalFrames: number
|
totalFrames: number
|
||||||
incidentCoord: { lat: number; lon: number }
|
incidentCoord: { lat: number; lon: number }
|
||||||
|
backwardParticles?: BackwardParticleStep[]
|
||||||
}
|
}
|
||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||||
@ -1100,6 +1100,7 @@ export function MapView({
|
|||||||
replayFrame: backtrackReplay.replayFrame,
|
replayFrame: backtrackReplay.replayFrame,
|
||||||
totalFrames: backtrackReplay.totalFrames,
|
totalFrames: backtrackReplay.totalFrames,
|
||||||
incidentCoord: backtrackReplay.incidentCoord,
|
incidentCoord: backtrackReplay.incidentCoord,
|
||||||
|
backwardParticles: backtrackReplay.backwardParticles,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,3 +43,19 @@ export interface CollisionEvent {
|
|||||||
export type BacktrackPhase = 'conditions' | 'analyzing' | 'results' | 'replay'
|
export type BacktrackPhase = 'conditions' | 'analyzing' | 'results' | 'replay'
|
||||||
|
|
||||||
export const TOTAL_REPLAY_FRAMES = 120
|
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 문자열
|
||||||
|
}
|
||||||
|
|
||||||
|
// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열
|
||||||
|
export type BackwardParticleStep = [number, number][]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '@common/types/backtrack'
|
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions } from '@common/types/backtrack'
|
||||||
|
|
||||||
interface BacktrackModalProps {
|
interface BacktrackModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@ -7,10 +7,24 @@ interface BacktrackModalProps {
|
|||||||
phase: BacktrackPhase
|
phase: BacktrackPhase
|
||||||
conditions: BacktrackConditions
|
conditions: BacktrackConditions
|
||||||
vessels: BacktrackVessel[]
|
vessels: BacktrackVessel[]
|
||||||
onRunAnalysis: () => void
|
onRunAnalysis: (input: BacktrackInputConditions) => void
|
||||||
onStartReplay: () => 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({
|
export function BacktrackModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@ -22,6 +36,11 @@ export function BacktrackModal({
|
|||||||
}: BacktrackModalProps) {
|
}: BacktrackModalProps) {
|
||||||
const backdropRef = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (e.target === backdropRef.current) onClose()
|
if (e.target === backdropRef.current) onClose()
|
||||||
@ -32,6 +51,21 @@ export function BacktrackModal({
|
|||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const inputDisabled = phase !== 'conditions'
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 8px',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--bd)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: 'var(--t1)',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontFamily: 'var(--fM)',
|
||||||
|
outline: 'none',
|
||||||
|
opacity: inputDisabled ? 0.6 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
@ -92,24 +126,80 @@ export function BacktrackModal({
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px',
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px',
|
||||||
}}>
|
}}>
|
||||||
{[
|
{/* 유출 추정 시각 */}
|
||||||
{ label: '유출 추정 시각', value: conditions.estimatedSpillTime },
|
<div style={{
|
||||||
{ label: '분석 범위', value: conditions.analysisRange },
|
padding: '10px 12px', background: 'var(--bg-card)',
|
||||||
{ label: '탐색 반경', value: conditions.searchRadius },
|
borderRadius: '8px',
|
||||||
{ label: '유출 위치', value: `${conditions.spillLocation.lat.toFixed(4)}°N, ${conditions.spillLocation.lon.toFixed(4)}°E` },
|
}} className="border border-stroke">
|
||||||
].map((item, i) => (
|
<div className="text-[9px] text-fg-disabled mb-1">
|
||||||
<div key={i} style={{
|
유출 추정 시각
|
||||||
padding: '10px 12px', background: 'var(--bg-card)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}} className="border border-stroke">
|
|
||||||
<div className="text-[9px] text-fg-disabled mb-1">
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div className="text-[12px] font-semibold font-mono">
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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(--bg-card)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}} className="border border-stroke">
|
||||||
|
<div className="text-[9px] text-fg-disabled 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(--bg-card)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}} className="border border-stroke">
|
||||||
|
<div className="text-[9px] text-fg-disabled 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-fg-disabled shrink-0">NM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유출 위치 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px', background: 'var(--bg-card)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}} className="border border-stroke">
|
||||||
|
<div className="text-[9px] text-fg-disabled 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={{
|
<div style={{
|
||||||
padding: '10px 12px', background: 'var(--bg-card)',
|
padding: '10px 12px', background: 'var(--bg-card)',
|
||||||
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
|
border: '1px solid rgba(168,85,247,0.3)', borderRadius: '8px',
|
||||||
@ -155,7 +245,7 @@ export function BacktrackModal({
|
|||||||
}} className="border-t border-stroke flex gap-2">
|
}} className="border-t border-stroke flex gap-2">
|
||||||
{phase === 'conditions' && (
|
{phase === 'conditions' && (
|
||||||
<button
|
<button
|
||||||
onClick={onRunAnalysis}
|
onClick={() => onRunAnalysis({ estimatedSpillTime: inputTime, analysisRange: inputRange, searchRadius: inputRadius })}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, t
|
|||||||
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
|
import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
|
||||||
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
||||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
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, BackwardParticleStep } from '@common/types/backtrack'
|
||||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
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'
|
import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi'
|
||||||
@ -194,6 +194,8 @@ export function OilSpillView() {
|
|||||||
})
|
})
|
||||||
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
||||||
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
||||||
|
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
|
||||||
|
const [backwardParticles, setBackwardParticles] = useState<BackwardParticleStep[]>([])
|
||||||
|
|
||||||
// 재계산 상태
|
// 재계산 상태
|
||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||||
@ -236,7 +238,7 @@ export function OilSpillView() {
|
|||||||
const loadBacktrackData = useCallback(async (acdntSn: number) => {
|
const loadBacktrackData = useCallback(async (acdntSn: number) => {
|
||||||
try {
|
try {
|
||||||
const bt = await fetchBacktrackByAcdnt(acdntSn)
|
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>
|
const rslt = bt.rsltData as Record<string, unknown>
|
||||||
if (Array.isArray(rslt.vessels)) {
|
if (Array.isArray(rslt.vessels)) {
|
||||||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||||||
@ -247,6 +249,10 @@ export function OilSpillView() {
|
|||||||
if (rslt.collisionEvent) {
|
if (rslt.collisionEvent) {
|
||||||
setCollisionEvent(rslt.collisionEvent as 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({
|
setBacktrackConditions({
|
||||||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
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시간',
|
analysisRange: bt.anlysRange || '±12시간',
|
||||||
@ -273,6 +279,9 @@ export function OilSpillView() {
|
|||||||
setBacktrackConditions(prev => ({
|
setBacktrackConditions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
spillLocation: incidentCoord ?? prev.spillLocation,
|
spillLocation: incidentCoord ?? prev.spillLocation,
|
||||||
|
estimatedSpillTime: selectedAnalysis?.occurredAt
|
||||||
|
? toLocalDateTimeStr(selectedAnalysis.occurredAt)
|
||||||
|
: prev.estimatedSpillTime,
|
||||||
}))
|
}))
|
||||||
if (selectedAnalysis) {
|
if (selectedAnalysis) {
|
||||||
loadBacktrackData(selectedAnalysis.acdntSn)
|
loadBacktrackData(selectedAnalysis.acdntSn)
|
||||||
@ -282,39 +291,32 @@ export function OilSpillView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRunBacktrackAnalysis = async () => {
|
const handleRunBacktrackAnalysis = async (input: BacktrackInputConditions) => {
|
||||||
if (!incidentCoord) return
|
if (!incidentCoord || !selectedAnalysis) return
|
||||||
setBacktrackPhase('analyzing')
|
setBacktrackPhase('analyzing')
|
||||||
try {
|
try {
|
||||||
if (selectedAnalysis) {
|
const anlysRangeStr = `±${input.analysisRange}시간`
|
||||||
const { backtrackSn } = await createBacktrack({
|
const bt = await createBacktrack({
|
||||||
acdntSn: selectedAnalysis.acdntSn,
|
acdntSn: selectedAnalysis.acdntSn,
|
||||||
lon: incidentCoord.lon,
|
lon: incidentCoord.lon,
|
||||||
lat: incidentCoord.lat,
|
lat: incidentCoord.lat,
|
||||||
})
|
estSpilDtm: input.estimatedSpillTime ? new Date(input.estimatedSpillTime).toISOString() : undefined,
|
||||||
// 생성 후 기존 결과 로딩 (시드 데이터 또는 엔진 처리 결과)
|
anlysRange: anlysRangeStr,
|
||||||
const bt = await fetchBacktrackByAcdnt(selectedAnalysis.acdntSn)
|
srchRadiusNm: input.searchRadius,
|
||||||
if (bt && bt.rsltData) {
|
})
|
||||||
const rslt = bt.rsltData as Record<string, unknown>
|
|
||||||
if (Array.isArray(rslt.vessels)) {
|
if (bt.execSttsCd === 'COMPLETED' && bt.rsltData) {
|
||||||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
const rslt = bt.rsltData as Record<string, unknown>
|
||||||
}
|
if (Array.isArray(rslt['vessels'])) setBacktrackVessels(rslt['vessels'] as BacktrackVessel[])
|
||||||
if (Array.isArray(rslt.replayShips)) {
|
if (Array.isArray(rslt['replayShips'])) setReplayShips(rslt['replayShips'] as ReplayShip[])
|
||||||
setReplayShips(rslt.replayShips as ReplayShip[])
|
if (rslt['collisionEvent']) setCollisionEvent(rslt['collisionEvent'] as CollisionEvent)
|
||||||
}
|
if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
|
||||||
if (rslt.collisionEvent) {
|
setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : [])
|
||||||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 }))
|
||||||
}
|
setBacktrackPhase('results')
|
||||||
setBacktrackConditions(prev => ({
|
} else {
|
||||||
...prev,
|
console.error('[prediction] 역추적 분석 실패:', bt.execSttsCd)
|
||||||
totalVessels: bt.totalVessels || 0,
|
setBacktrackPhase('conditions')
|
||||||
}))
|
|
||||||
setBacktrackPhase('results')
|
|
||||||
} else {
|
|
||||||
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
|
|
||||||
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
|
|
||||||
setBacktrackPhase('conditions')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[prediction] 역추적 분석 실패:', err)
|
console.error('[prediction] 역추적 분석 실패:', err)
|
||||||
@ -1070,6 +1072,7 @@ export function OilSpillView() {
|
|||||||
replayFrame,
|
replayFrame,
|
||||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
|
backwardParticles,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
showCurrent={displayControls.showCurrent}
|
showCurrent={displayControls.showCurrent}
|
||||||
showWind={displayControls.showWind}
|
showWind={displayControls.showWind}
|
||||||
@ -1259,6 +1262,8 @@ export function OilSpillView() {
|
|||||||
onClose={handleCloseReplay}
|
onClose={handleCloseReplay}
|
||||||
replayShips={replayShips}
|
replayShips={replayShips}
|
||||||
collisionEvent={collisionEvent}
|
collisionEvent={collisionEvent}
|
||||||
|
replayTimeRange={replayTimeRange ?? undefined}
|
||||||
|
hasBackwardParticles={backwardParticles.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -115,8 +115,9 @@ export const createBacktrack = async (input: {
|
|||||||
lat: number;
|
lat: number;
|
||||||
srchRadiusNm?: number;
|
srchRadiusNm?: number;
|
||||||
anlysRange?: string;
|
anlysRange?: string;
|
||||||
}): Promise<{ backtrackSn: number }> => {
|
estSpilDtm?: string;
|
||||||
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
|
}): Promise<BacktrackResult> => {
|
||||||
|
const response = await api.post<BacktrackResult>('/prediction/backtrack', input);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user