diff --git a/.claude/settings.json b/.claude/settings.json index 43453f7..868df2d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,6 +83,5 @@ ] } ] - }, - "allow": [] -} \ No newline at end of file + } +} diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 003eaf0..7af3ce8 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,7 +1,7 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-26", + "applied_date": "2026-03-30", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} \ No newline at end of file +} diff --git a/backend/src/prediction/backtrackAnalysisService.ts b/backend/src/prediction/backtrackAnalysisService.ts new file mode 100644 index 0000000..d7027d2 --- /dev/null +++ b/backend/src/prediction/backtrackAnalysisService.ts @@ -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 = { + '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; + 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 { + 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, + spillLat: number, + spillLon: number, + srchRadiusNm: number, + anlysHours: number, +): RankedVessel[] { + const anlysWindowSec = anlysHours * 3600; + + const scored = tracks.map(track => { + // 1. spatialScore (40%): 사고 지점과의 최근접 거리 + let minDist = Infinity; + let minDistIdx = 0; + track.geometry.forEach(([lon, lat], idx) => { + const d = haversineNm(lat, lon, spillLat, spillLon); + if (d < minDist) { minDist = d; minDistIdx = idx; } + }); + const spatialScore = Math.max(0, 1 - minDist / srchRadiusNm); + + // 2. temporalScore (25%): 탐색구역 체류시간 / 분석 윈도우 + const hits = hitDetails[track.vesselId] ?? []; + const totalTimeInZoneSec = hits.reduce((sum, h) => sum + (h.exitTimestamp - h.entryTimestamp), 0); + const temporalScore = Math.min(1, totalTimeInZoneSec / anlysWindowSec); + + // 3. behaviorScore (20%): 급감속 + AIS 단절 + const speedDrop = detectSpeedDrop(track.speeds); + const aisGap = detectAisGapFromTimestamps(track.timestamps); + const behaviorScore = Math.min(1, speedDrop * 0.6 + (aisGap ? 0.4 : 0)); + + // 4. vesselTypeScore (15%): 선박 유형별 위험도 + const vesselTpRaw = parseInt(track.shipType ?? '', 10); + const vesselTp = isNaN(vesselTpRaw) ? null : vesselTpRaw; + const vesselTypeScore = getVesselTypeScore(vesselTp); + + const rawScore = 0.40 * spatialScore + 0.25 * temporalScore + 0.20 * behaviorScore + 0.15 * vesselTypeScore; + + const speedChangeLabel = speedDrop > 0.5 ? '급감속' : speedDrop > 0.2 ? '감속' : '정상'; + const aisStatusLabel = aisGap ? 'AIS단절' : '정상'; + + const closestTs = track.timestamps[minDistIdx]; + const closestDate = closestTs ? new Date(Number(closestTs) * 1000) : new Date(); + + const descParts: string[] = []; + if (speedDrop > 0.5) descParts.push(`${toTimeLabel(closestDate)} 급감속 감지`); + if (aisGap) descParts.push('AIS 신호 단절 구간 존재'); + if (minDist < 1) descParts.push(`최근접 ${minDist.toFixed(2)} NM 이내 통과`); + + const imo = track.chnPrmShipInfo?.imo?.toString() ?? ''; + const vesselName = track.shipName || track.chnPrmShipInfo?.name || `MMSI:${track.vesselId}`; + + return { + mmsi: track.vesselId, + imo, + name: vesselName, + type: vesselTypeToLabel(vesselTp), + flag: track.nationalCode ?? '', + flagCountry: track.nationalCode ?? '', + closestTime: toTimeLabel(closestDate), + closestDistance: Math.round(minDist * 100) / 100, + speedChange: speedChangeLabel, + aisStatus: aisStatusLabel, + description: descParts.join(' · '), + probability: 0, + rank: 0, + color: '', + _rawScore: rawScore, + _track: track, + _minDistIdx: minDistIdx, + }; + }); + + scored.sort((a, b) => b._rawScore - a._rawScore); + const top = scored.slice(0, 10); + const maxScore = top[0]?._rawScore ?? 1; + + return top.map((v, i) => ({ + ...v, + rank: i + 1, + probability: Math.round((v._rawScore / maxScore) * 95 * 10) / 10, + color: RANK_COLORS[i] ?? '#888888', + })); +} + +interface ReplayShip { + vesselName: string; + color: string; + path: Array<{ lat: number; lon: number }>; + speedLabels: string[]; +} + +// 리플레이용 선박 경로 빌드 (상위 5척, API geometry 직접 사용) +function buildReplayShipsFromApi(ranked: RankedVessel[]): ReplayShip[] { + return ranked.slice(0, 5).map(v => ({ + vesselName: v.name, + color: v.color, + path: v._track.geometry.map(([lon, lat]) => ({ lat, lon })), + speedLabels: v._track.speeds.map(s => `${s.toFixed(1)} kts`), + })); +} + +interface CollisionEvent { + position: { lat: number; lon: number }; + timeLabel: string; + progressPercent: number; +} + +// 최고 확률 선박의 최근접 지점 이벤트 +function findCollisionEventFromApi( + ranked: RankedVessel[], + startTime: Date, + endTime: Date, +): CollisionEvent | null { + const top = ranked[0]; + if (!top) return null; + + const idx = top._minDistIdx; + const ts = top._track.timestamps[idx]; + const pointDate = ts ? new Date(Number(ts) * 1000) : new Date(); + + const totalMs = endTime.getTime() - startTime.getTime(); + const pointMs = pointDate.getTime() - startTime.getTime(); + const progressPercent = totalMs > 0 + ? Math.max(0, Math.min(100, Math.round((pointMs / totalMs) * 100))) + : 0; + + const point = top._track.geometry[idx]; + const [lon, lat] = point ?? [0, 0]; + + return { + position: { lat, lon }, + timeLabel: toTimeLabel(pointDate) + ' 최근접', + progressPercent, + }; +} + +// ============================================================ +// 메인 분석 함수 (외부에서 호출) +// ============================================================ +export async function runBacktrackAnalysis(backtrackSn: number): Promise { + await wingPool.query( + `UPDATE wing.BACKTRACK SET EXEC_STTS_CD='RUNNING' WHERE BACKTRACK_SN=$1`, + [backtrackSn], + ); + + try { + const bt = await getBacktrack(backtrackSn); + if (!bt || bt.lat == null || bt.lon == null || !bt.estSpilDtm) { + throw new Error('역추적 레코드 정보 불충분 (lat/lon/estSpilDtm 필요)'); + } + + const anlysHours = parseAnalysisHours(bt.anlysRange ?? '12'); + const spillTime = new Date(bt.estSpilDtm); + const analysisStart = new Date(spillTime.getTime() - anlysHours * 3600000); + + // SPIL_DATA에서 유출량 및 유종 조회 + let matVol: number | null = null; + let matTy: string | undefined; + try { + const { rows: spillRows } = await wingPool.query( + `SELECT SPIL_QTY, OIL_TP_CD FROM wing.SPIL_DATA WHERE ACDNT_SN=$1 ORDER BY SPIL_DATA_SN ASC LIMIT 1`, + [bt.acdntSn], + ); + if (spillRows.length > 0) { + const row = spillRows[0] as Record; + matVol = row['spil_qty'] != null ? Number(row['spil_qty']) : null; + const oilTpCd = row['oil_tp_cd'] as string | null; + matTy = oilTpCd ? (OIL_TYPE_MAP[oilTpCd] ?? oilTpCd) : undefined; + } + } catch (spillErr) { + console.warn('[backtrack] SPIL_DATA 조회 실패, matVol 없이 진행:', spillErr); + } + + // Python 역방향 시뮬레이션 실행 (파티클 시각화용) + 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], + ); + } +} diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 2c3dde0..e5ec6cb 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -1,4 +1,5 @@ import { wingPool } from '../db/wingDb.js'; +import { runBacktrackAnalysis } from './backtrackAnalysisService.js'; interface PredictionAnalysis { acdntSn: number; @@ -373,7 +374,7 @@ function rowToBacktrack(r: Record): BacktrackResult { return { backtrackSn: Number(r['backtrack_sn']), acdntSn: Number(r['acdnt_sn']), - estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null, + estSpilDtm: r['est_spil_dtm'] ? new Date(r['est_spil_dtm'] as string | Date).toISOString() : null, anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null, lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null, lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null, @@ -387,15 +388,15 @@ function rowToBacktrack(r: Record): BacktrackResult { export async function createBacktrack( input: CreateBacktrackInput, -): Promise<{ backtrackSn: number }> { +): Promise { const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input; const sql = ` INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD) VALUES ( - $1, $2, $3, - ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), - $3 || ' + ' || $2, + $1, $2::double precision, $3::double precision, + ST_SetSRID(ST_MakePoint($3::double precision, $2::double precision), 4326), + $3::text || ' + ' || $2::text, $4, $5, $6, 'PENDING' ) RETURNING BACKTRACK_SN @@ -405,8 +406,14 @@ export async function createBacktrack( acdntSn, lat, lon, estSpilDtm || null, anlysRange || null, srchRadiusNm || null, ]); + const backtrackSn = Number((rows[0] as Record)['backtrack_sn']); - return { backtrackSn: Number((rows[0] as Record)['backtrack_sn']) }; + // 동기 분석 (완료까지 대기 후 결과 반환) + await runBacktrackAnalysis(backtrackSn); + + const result = await getBacktrack(backtrackSn); + if (!result) throw new Error('역추적 결과를 찾을 수 없습니다'); + return result; } export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> { diff --git a/backend/src/routes/tiles.ts b/backend/src/routes/tiles.ts new file mode 100644 index 0000000..3b46048 --- /dev/null +++ b/backend/src/routes/tiles.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; + +const router = Router(); + +const VWORLD_API_KEY = process.env.VWORLD_API_KEY || ''; + +// GET /api/tiles/vworld/:z/:y/:x — VWorld WMTS 위성타일 프록시 (CORS 우회) +// VWorld는 브라우저 직접 요청에 CORS 헤더를 반환하지 않으므로 서버에서 중계 +router.get('/vworld/:z/:y/:x', async (req, res) => { + const { z, y } = req.params; + const x = req.params.x.replace(/\.jpeg$/i, ''); + + // z/y/x 정수 검증 (SSRF 방지) + if (!/^\d+$/.test(z) || !/^\d+$/.test(y) || !/^\d+$/.test(x)) { + res.status(400).json({ error: '잘못된 타일 좌표' }); + return; + } + + if (!VWORLD_API_KEY) { + res.status(503).json({ error: 'VWorld API 키가 설정되지 않았습니다.' }); + return; + } + + const tileUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/${z}/${y}/${x}.jpeg`; + + try { + const upstream = await fetch(tileUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' }, + }); + + if (!upstream.ok) { + res.status(upstream.status).end(); + return; + } + + const contentType = upstream.headers.get('content-type') || 'image/jpeg'; + const cacheControl = upstream.headers.get('cache-control') || 'public, max-age=86400'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', cacheControl); + + const buffer = await upstream.arrayBuffer(); + res.end(Buffer.from(buffer)); + } catch { + res.status(502).json({ error: 'VWorld 타일 서버 연결 실패' }); + } +}); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index cf7e6a1..a577c9d 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser' import { testWingDbConnection } from './db/wingDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' +import tilesRouter from './routes/tiles.js' import authRouter from './auth/authRouter.js' import userRouter from './users/userRouter.js' import roleRouter from './roles/roleRouter.js' @@ -105,7 +106,8 @@ const generalLimiter = rateLimit({ legacyHeaders: false, skip: (req) => { // HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외 - return req.path.startsWith('/api/aerial/cctv/stream-proxy'); + return req.path.startsWith('/api/aerial/cctv/stream-proxy') || + req.path.startsWith('/api/tiles/'); }, message: { error: '요청 횟수 초과', @@ -172,6 +174,7 @@ app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) app.use('/api/monitor', monitorRouter) +app.use('/api/tiles', tilesRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/030_ais_track.sql b/database/migration/030_ais_track.sql new file mode 100644 index 0000000..15f576c --- /dev/null +++ b/database/migration/030_ais_track.sql @@ -0,0 +1,19 @@ +-- AIS 선박 위치 이력 테이블 +CREATE TABLE IF NOT EXISTS wing.AIS_TRACK ( + AIS_TRACK_SN SERIAL PRIMARY KEY, + MMSI VARCHAR(12) NOT NULL, + IMO VARCHAR(12), + VESSEL_NM VARCHAR(100), + VESSEL_TP SMALLINT, + LAT NUMERIC(9,6), + LON NUMERIC(10,6), + SPEED NUMERIC(5,1), + COURSE NUMERIC(5,1), + NAV_STATUS SMALLINT, + OBS_DTM TIMESTAMPTZ NOT NULL, + GEOM GEOMETRY(Point, 4326), + SRC_CD VARCHAR(20) DEFAULT 'API' +); +CREATE INDEX IF NOT EXISTS idx_ais_track_mmsi ON wing.AIS_TRACK(MMSI); +CREATE INDEX IF NOT EXISTS idx_ais_track_obs_dtm ON wing.AIS_TRACK(OBS_DTM); +CREATE INDEX IF NOT EXISTS idx_ais_track_geom ON wing.AIS_TRACK USING GIST(GEOM); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a6cb6b3..de36c3f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -13,6 +13,30 @@ - PretendardGOV 폰트 적용 - 라이트 테마 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] ### 추가 @@ -22,26 +46,20 @@ - 보고서: HWPX 이미지 내보내기 구조를 HWPX 스펙(hc:img + manifest 방식)으로 수정 - 확산예측: 분석 목록 정렬 기준 변경 (RUN_DTM DESC 우선) -## [2026-03-25.2] - -### 추가 -- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) -- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) -- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션) - -### 변경 -- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용 -- 사고: 선택된 항목 재클릭 시 선택 해제 지원 - ## [2026-03-25] ### 추가 - 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회) - DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn) - 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE) +- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) +- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) +- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션) ### 변경 - 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등) +- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용 +- 사고: 선택된 항목 재클릭 시 선택 해제 지원 ## [2026-03-24] diff --git a/frontend/src/common/components/map/BacktrackReplayBar.tsx b/frontend/src/common/components/map/BacktrackReplayBar.tsx index 10cd979..f73faa8 100755 --- a/frontend/src/common/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/common/components/map/BacktrackReplayBar.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect } from 'react' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' interface BacktrackReplayBarProps { @@ -11,6 +12,8 @@ interface BacktrackReplayBarProps { onClose: () => void replayShips: ReplayShip[] collisionEvent: CollisionEvent | null + replayTimeRange?: { start: string; end: string } + hasBackwardParticles?: boolean } export function BacktrackReplayBar({ @@ -24,23 +27,88 @@ export function BacktrackReplayBar({ onClose, replayShips, collisionEvent, + replayTimeRange, + hasBackwardParticles, }: BacktrackReplayBarProps) { 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 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` + // 드래그 시크 + const barRef = useRef(null) + const isDraggingRef = useRef(false) + const onSeekRef = useRef(onSeek) + const totalFramesRef = useRef(totalFrames) - const handleSeekClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect() + useEffect(() => { onSeekRef.current = onSeek }, [onSeek]) + 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) => { + 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)) 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 (
@@ -97,7 +165,7 @@ export function BacktrackReplayBar({
{/* Play/Pause */} {/* Timeline */}
{/* Progress bar */}
- 18:30 + {startLabel} {currentTimeLabel} - 06:30 + {endLabel}
{/* Legend row */} -
+
{replayShips.map((ship) => (
@@ -177,6 +246,12 @@ export function BacktrackReplayBar({
))} + {hasBackwardParticles && ( +
+
+ 역방향 예측 +
+ )}
) diff --git a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx index 42bc289..c80f47c 100755 --- a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx @@ -1,7 +1,29 @@ -import { ScatterplotLayer, PathLayer } from '@deck.gl/layers' -import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' +import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers' +import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack' import { hexToRgba } from './mapUtils' +// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산 +function convexHull(points: [number, number][]): [number, number][] { + if (points.length < 3) return points + const cross = (O: [number, number], A: [number, number], B: [number, number]) => + (A[0] - O[0]) * (B[1] - O[1]) - (A[1] - O[1]) * (B[0] - O[0]) + const sorted = [...points].sort((a, b) => a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]) + const lower: [number, number][] = [] + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop() + lower.push(p) + } + const upper: [number, number][] = [] + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i] + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop() + upper.push(p) + } + lower.pop() + upper.pop() + return [...lower, ...upper] +} + function getInterpolatedPosition( path: ReplayPathPoint[], frame: number, @@ -24,11 +46,14 @@ interface BacktrackReplayParams { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } +const BACKWARD_COLOR: [number, number, number, number] = [168, 85, 247, 160] + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createBacktrackLayers(params: BacktrackReplayParams): any[] { - const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params + const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord, backwardParticles } = params // eslint-disable-next-line @typescript-eslint/no-explicit-any const layers: any[] = [] const progress = replayFrame / totalFrames @@ -145,5 +170,46 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] { } } + // 역방향 예측 파티클 + 전체 경로 외각 폴리곤 + if (backwardParticles && backwardParticles.length > 0) { + // 전체 스텝의 모든 파티클을 합쳐 외각 폴리곤 계산 (정적 — 항상 표시) + const allPoints = backwardParticles.flat() + if (allPoints.length >= 3) { + const hull = convexHull(allPoints) + if (hull.length >= 3) { + layers.push( + new PolygonLayer({ + id: 'bt-backward-hull', + data: [{ polygon: hull }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [168, 85, 247, 18], + getLineColor: [168, 85, 247, 110], + getLineWidth: 1, + stroked: true, + filled: true, + lineWidthMinPixels: 1, + }) + ) + } + } + + // 현재 프레임 파티클 + const stepIndex = Math.round((1 - progress) * (backwardParticles.length - 1)) + const particles = backwardParticles[stepIndex] ?? [] + if (particles.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'bt-backward-particles', + data: particles, + getPosition: (d: [number, number]) => d, + getRadius: 3, + getFillColor: BACKWARD_COLOR, + radiusMinPixels: 2.5, + radiusMaxPixels: 5, + }) + ) + } + } + return layers } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 1520894..3af8aa1 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -11,7 +11,7 @@ import type { PredictionModel, SensitiveResource } from '@tabs/prediction/compon import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' -import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' +import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' import { buildMeasureLayers } from './measureLayers' import { MeasureOverlay } from './MeasureOverlay' @@ -20,7 +20,6 @@ import { hexToRgba } from './mapUtils' import { useMapStore } from '@common/store/mapStore' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' -const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || '' // 인천 송도 국제도시 const DEFAULT_CENTER: [number, number] = [37.39, 126.64] @@ -220,7 +219,7 @@ const SATELLITE_3D_STYLE: StyleSpecification = { sources: { 'vworld-satellite': { type: 'raster', - tiles: [`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`], + tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], tileSize: 256, attribution: '© 국토지리정보원 VWorld', }, @@ -358,6 +357,7 @@ interface MapViewProps { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } sensitiveResources?: SensitiveResource[] sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null @@ -1100,6 +1100,7 @@ export function MapView({ replayFrame: backtrackReplay.replayFrame, totalFrames: backtrackReplay.totalFrames, incidentCoord: backtrackReplay.incidentCoord, + backwardParticles: backtrackReplay.backwardParticles, })) } diff --git a/frontend/src/common/types/backtrack.ts b/frontend/src/common/types/backtrack.ts index 3511ed2..269cc10 100755 --- a/frontend/src/common/types/backtrack.ts +++ b/frontend/src/common/types/backtrack.ts @@ -43,3 +43,19 @@ 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 문자열 +} + +// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열 +export type BackwardParticleStep = [number, number][] diff --git a/frontend/src/tabs/prediction/components/BacktrackModal.tsx b/frontend/src/tabs/prediction/components/BacktrackModal.tsx index 918cce9..2a0e119 100755 --- a/frontend/src/tabs/prediction/components/BacktrackModal.tsx +++ b/frontend/src/tabs/prediction/components/BacktrackModal.tsx @@ -1,5 +1,5 @@ -import { useRef, useEffect } from 'react' -import type { BacktrackPhase, BacktrackVessel, BacktrackConditions } from '@common/types/backtrack' +import { useRef, useEffect, useState } from 'react' +import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions } from '@common/types/backtrack' interface BacktrackModalProps { isOpen: boolean @@ -7,10 +7,24 @@ interface BacktrackModalProps { phase: BacktrackPhase conditions: BacktrackConditions vessels: BacktrackVessel[] - onRunAnalysis: () => void + onRunAnalysis: (input: BacktrackInputConditions) => void onStartReplay: () => void } +const toDateTimeLocalValue = (raw: string): string => { + if (!raw) return '' + const d = new Date(raw) + if (isNaN(d.getTime())) return '' + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +const nowDateTimeLocalValue = (): string => { + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + export function BacktrackModal({ isOpen, onClose, @@ -22,6 +36,11 @@ export function BacktrackModal({ }: BacktrackModalProps) { const backdropRef = useRef(null) + const [inputTimeOverride, setInputTime] = useState(undefined) + const inputTime = inputTimeOverride ?? toDateTimeLocalValue(conditions.estimatedSpillTime) ?? nowDateTimeLocalValue() + const [inputRange, setInputRange] = useState('12') + const [inputRadius, setInputRadius] = useState(10) + useEffect(() => { const handler = (e: MouseEvent) => { if (e.target === backdropRef.current) onClose() @@ -32,6 +51,21 @@ export function BacktrackModal({ if (!isOpen) return null + const inputDisabled = phase !== 'conditions' + + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '6px 8px', + background: 'var(--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 (
- {[ - { label: '유출 추정 시각', value: conditions.estimatedSpillTime }, - { label: '분석 범위', value: conditions.analysisRange }, - { label: '탐색 반경', value: conditions.searchRadius }, - { label: '유출 위치', value: `${conditions.spillLocation.lat.toFixed(4)}°N, ${conditions.spillLocation.lon.toFixed(4)}°E` }, - ].map((item, i) => ( -
-
- {item.label} -
-
- {item.value} -
+ {/* 유출 추정 시각 */} +
+
+ 유출 추정 시각
- ))} + setInputTime(e.target.value)} + disabled={inputDisabled} + style={inputStyle} + /> +
+ + {/* 분석 범위 */} +
+
+ 분석 범위 +
+ +
+ + {/* 탐색 반경 */} +
+
+ 탐색 반경 +
+
+ setInputRadius(Number(e.target.value))} + disabled={inputDisabled} + min={1} + max={100} + step={0.5} + style={{ ...inputStyle, flex: 1 }} + /> + NM +
+
+ + {/* 유출 위치 */} +
+
+ 유출 위치 +
+
+ {conditions.spillLocation.lat.toFixed(4)}°N, {conditions.spillLocation.lon.toFixed(4)}°E +
+
+ + {/* 분석 대상 선박 */}
{phase === 'conditions' && (